diff --git a/.github/workflows/automated_integration_test.yml b/.github/workflows/automated_integration_test.yml deleted file mode 100644 index 9b803a1f0..000000000 --- a/.github/workflows/automated_integration_test.yml +++ /dev/null @@ -1,487 +0,0 @@ - -name: Automated Integration Tests - -# Temporarily disabled - uncomment to re-enable -on: - # push: - # pull_request: - # branches: [main] - workflow_dispatch: # This is to manually trigger if needed -defaults: - run: - shell: bash -jobs: - Automated_integration_test: - runs-on: linux-amd64 - timeout-minutes: 90 - container: - image: ghcr.io/cake-tech/cake_wallet:debian12-flutter3.27.0-go1.24.1-ruststablenightly - env: - STORE_PASS: test@cake_wallet - KEY_PASS: test@cake_wallet - MONEROC_CACHE_DIR_ROOT: /opt/generic_cache - BRANCH_NAME: ${{ github.head_ref || github.ref_name }} - ANDROID_AVD_HOME: /root/.android/avd - volumes: - - /opt/cw_cache_android/root/.cache:/root/.cache - - /opt/cw_cache_android/root/.android/avd/:/root/.android/avd - - /opt/cw_cache_android/root/.ccache:/root/.ccache - - /opt/cw_cache_android/root/.pub-cache/:/root/.pub-cache - - /opt/cw_cache_android/root/.gradle/:/root/.gradle - - /opt/cw_cache_android/root/.android/:/root/.android - - /opt/cw_cache_android/root/go/pkg:/root/go/pkg - - /opt/cw_cache_android/opt/generic_cache:/opt/generic_cache - - /dev/kvm:/dev/kvm - strategy: - matrix: - api-level: [29] - - steps: - - name: Fix github actions messing up $HOME... - run: 'echo HOME=/root | sudo tee -a $GITHUB_ENV' - - uses: actions/checkout@v4 - with: - ref: ${{ github.event.pull_request.head.sha || github.sha }} - - name: configure git - run: | - git config --global --add safe.directory '*' - git config --global user.email "ci@cakewallet.com" - git config --global user.name "CakeWallet CI" - - name: Get the full commit message - run: | - FULL_MESSAGE="$(git log -1 --pretty=%B)" - echo "message<> $GITHUB_ENV - echo "$FULL_MESSAGE" >> $GITHUB_ENV - echo "EOF" >> $GITHUB_ENV - - - name: Add secrets - run: | - touch cw_bitcoin/lib/.secrets.g.dart - touch cw_evm/lib/.secrets.g.dart - touch cw_solana/lib/.secrets.g.dart - touch cw_core/lib/.secrets.g.dart - touch cw_nano/lib/.secrets.g.dart - touch cw_tron/lib/.secrets.g.dart - - echo "const polygonScanApiKey = '${{ secrets.POLYGON_SCAN_API_KEY }}';" >> cw_evm/lib/.secrets.g.dart - echo "const etherScanApiKey = '${{ secrets.ETHER_SCAN_API_KEY }}';" >> cw_evm/lib/.secrets.g.dart - echo "const moralisApiKey = '${{ secrets.MORALIS_API_KEY }}';" >> cw_evm/lib/.secrets.g.dart - echo "const nowNodesApiKey = '${{ secrets.EVM_NOWNODES_API_KEY }}';" >> cw_evm/lib/.secrets.g.dart - echo "const ankrApiKey = '${{ secrets.ANKR_API_KEY }}';" >> cw_solana/lib/.secrets.g.dart - echo "const chainStackApiKey = '${{ secrets.CHAIN_STACK_API_KEY }}';" >> cw_solana/lib/.secrets.g.dart - echo "const moralisApiKey = '${{ secrets.MORALIS_API_KEY }}';" >> cw_solana/lib/.secrets.g.dart - - echo "const nano2ApiKey = '${{ secrets.NANO2_API_KEY }}';" >> cw_nano/lib/.secrets.g.dart - echo "const nanoNowNodesApiKey = '${{ secrets.NANO_NOW_NODES_API_KEY }}';" >> cw_nano/lib/.secrets.g.dart - - echo "const tronGridApiKey = '${{ secrets.TRON_GRID_API_KEY }}';" >> cw_tron/lib/.secrets.g.dart - echo "const tronNowNodesApiKey = '${{ secrets.TRON_NOW_NODES_API_KEY }}';" >> cw_tron/lib/.secrets.g.dart - - echo "const breezApiKey = '${{ secrets.BREEZ_API_KEY }}';" >> cw_bitcoin/lib/.secrets.g.dart - - echo "${{ secrets.MAIN_SECRETS_FILE }}" | base64 --decode > lib/.secrets.g.dart - - - name: prepare monero_c and cache - run: | - export MONEROC_HASH=$(cat scripts/prepare_moneroc.sh | grep 'git checkout' | xargs | awk '{ print $3 }') - echo MONEROC_HASH=$MONEROC_HASH >> /etc/environment - mkdir -p "$MONEROC_CACHE_DIR_ROOT/moneroc-$MONEROC_HASH/monero_c" - pushd scripts - ln -s "$MONEROC_CACHE_DIR_ROOT/moneroc-$MONEROC_HASH/monero_c" - ./prepare_moneroc.sh - popd - pushd scripts/monero_c - mkdir -p "$MONEROC_CACHE_DIR_ROOT/_cache/contrib/depends/built" || true - mkdir -p "$MONEROC_CACHE_DIR_ROOT/_cache/monero/contrib/depends/built" || true - mkdir -p "$MONEROC_CACHE_DIR_ROOT/_cache/wownero/contrib/depends/built" || true - mkdir -p "$MONEROC_CACHE_DIR_ROOT/_cache/contrib/depends/sources" || true - mkdir -p "$MONEROC_CACHE_DIR_ROOT/_cache/monero/contrib/depends/sources" || true - mkdir -p "$MONEROC_CACHE_DIR_ROOT/_cache/wownero/contrib/depends/sources" || true - - rm -rf "$PWD/contrib/depends/built" "$PWD/monero/contrib/depends/built" "$PWD/wownero/contrib/depends/built" - rm -rf "$PWD/contrib/depends/sources" "$PWD/monero/contrib/depends/sources" "$PWD/wownero/contrib/depends/sources" - mkdir -p contrib/depends || true - ln -sf "$MONEROC_CACHE_DIR_ROOT/_cache/contrib/depends/built" "$PWD/contrib/depends/built" - ln -sf "$MONEROC_CACHE_DIR_ROOT/_cache/monero/contrib/depends/built" "$PWD/monero/contrib/depends/built" - ln -sf "$MONEROC_CACHE_DIR_ROOT/_cache/wownero/contrib/depends/built" "$PWD/wownero/contrib/depends/built" - ln -sf "$MONEROC_CACHE_DIR_ROOT/_cache/contrib/depends/sources" "$PWD/contrib/depends/sources" - ln -sf "$MONEROC_CACHE_DIR_ROOT/_cache/monero/contrib/depends/sources" "$PWD/monero/contrib/depends/sources" - ln -sf "$MONEROC_CACHE_DIR_ROOT/_cache/wownero/contrib/depends/sources" "$PWD/wownero/contrib/depends/sources" - popd - - - name: Generate KeyStore - run: | - pushd /opt/generic_cache - if [[ ! -f key.jks ]]; - then - keytool -genkey -v -keystore key.jks -keyalg RSA -keysize 2048 -validity 10000 -alias testKey -noprompt -dname "CN=CakeWallet, OU=CakeWallet, O=CakeWallet, L=Florida, S=America, C=USA" -storepass $STORE_PASS -keypass $KEY_PASS - else - echo "$PWD/key.jks exist, not generating" - fi - popd - cp /opt/generic_cache/key.jks android/app - - - name: Execute Build and Setup Commands - run: | - pushd scripts/android - source ./app_env.sh cakewallet - ./app_config.sh - popd - - - name: Build monero_c - run: | - pushd scripts/android/ - source ./app_env.sh cakewallet - ./build_monero_all.sh - popd - - - name: Install Flutter dependencies - run: | - flutter pub get - - - name: Build mwebd - run: | - set -x -e - export MWEBD_HASH=$(cat scripts/android/build_mwebd.sh | grep 'git reset --hard' | xargs | awk '{ print $4 }') - echo MWEBD_HASH=$MWEBD_HASH >> /etc/environment - pushd scripts/android - gomobile init; - ./build_mwebd.sh - popd - - - name: Build Decred - run: | - set -x -e - pushd scripts/android - ./build_decred.sh - popd - - - name: Build generated code - run: | - flutter --version - flutter pub get - ./model_generator.sh - - - name: Generate key properties - run: | - dart run tool/generate_android_key_properties.dart keyAlias=testKey storeFile=key.jks storePassword=$STORE_PASS keyPassword=$KEY_PASS - - - name: Generate localization - run: | - dart run tool/generate_localization.dart - - - name: Rename app - run: | - sanitized_branch_name=${BRANCH_NAME#origin/} # Remove 'origin/' prefix if it exists - sanitized_branch_name=${sanitized_branch_name:0:16} # Take only the first 16 characters - sanitized_branch_name=$(echo "$sanitized_branch_name" | tr '[:upper:]' '[:lower:]') # Convert to lowercase - sanitized_branch_name=$(echo "$sanitized_branch_name" | sed 's/[^a-z0-9]//g') # Remove all special characters - - echo -e "id=com.cakewallet.test_${sanitized_branch_name}\nname=${BRANCH_NAME}" > android/app.properties - - - name: Build - run: | - flutter build apk --dart-define=hasDevOptions=true --release --split-per-abi - - - name: Rename apk file - run: | - sanitized_branch_name=$(grep '^id=' android/app.properties | cut -d'=' -f2 | sed 's/com\.cakewallet\.test_//') - cd build/app/outputs/flutter-apk - mkdir test-apk - - cp app-arm64-v8a-release.apk test-apk/${sanitized_branch_name}.apk - cp app-x86_64-release.apk test-apk/${sanitized_branch_name}_x86.apk - echo "APK files created: test-apk/${sanitized_branch_name}.apk and test-apk/${sanitized_branch_name}_x86.apk" - - - name: Find APK file - id: find_apk - run: | - set -x - # Read the sanitized branch name from the app.properties file - sanitized_branch_name=$(grep '^id=' android/app.properties | cut -d'=' -f2 | sed 's/com\.cakewallet\.test_//') - - echo "Looking for APK file: build/app/outputs/flutter-apk/test-apk/${sanitized_branch_name}.apk" - ls -la build/app/outputs/flutter-apk/test-apk/ || echo "test-apk directory not found" - apk_file=$(ls build/app/outputs/flutter-apk/test-apk/${sanitized_branch_name}.apk || exit 1) - echo "APK_FILE=$apk_file" >> $GITHUB_ENV - echo "APK file found: $apk_file" - ls -la "$apk_file" - - - name: šŸ” Debug System Environment - run: | - echo "=== System Information ===" - uname -a - echo "=== Container Info ===" - cat /proc/version || echo "Cannot read /proc/version" - echo "=== KVM Device Check ===" - ls -la /dev/kvm || echo "KVM device not found" - echo "=== udev Status ===" - which udevadm || echo "udevadm not found" - echo "=== Current User ===" - whoami - echo "=== Groups ===" - groups - echo "=== Environment ===" - env | grep -E "(HOME|USER|PATH)" | sort - - - name: 🧹 Clean up existing emulators - run: | - echo "=== Cleaning up existing emulators ===" - # Kill any existing emulator processes - pkill -f emulator || echo "No emulator processes found" - # Kill any existing adb processes - pkill -f adb || echo "No adb processes found" - # Wait a moment for processes to terminate - sleep 2 - # Start adb server fresh - adb start-server || echo "Failed to start adb server" - # Kill adb server to ensure clean state - adb kill-server || echo "Failed to kill adb server" - echo "=== Cleanup complete ===" - - - name: šŸ” Verify AVD Configuration - run: | - echo "=== AVD Configuration Check ===" - - # Check if AVD directory exists - echo "Checking AVD directory..." - ls -la ~/.android/avd/ || echo "AVD directory not found" - - # List available AVDs - echo "Available AVDs:" - emulator -list-avds || echo "Failed to list AVDs" - - # Check Android SDK location - echo "Android SDK location:" - echo $ANDROID_HOME - echo $ANDROID_SDK_ROOT - - # Check emulator binary - echo "Emulator binary:" - which emulator || echo "Emulator not found in PATH" - emulator -version || echo "Failed to get emulator version" - - echo "=== AVD Check Complete ===" - - - name: 🦾 Enable KVM - run: | - echo "=== KVM Setup and Verification ===" - - # Check if KVM device exists - if [ -e /dev/kvm ]; then - echo "āœ… KVM device found at /dev/kvm" - - # Check current permissions - echo "Current KVM permissions:" - ls -la /dev/kvm - - # Set proper permissions - sudo chmod 666 /dev/kvm || echo "āš ļø Failed to set KVM permissions" - - # Verify permissions were set - if [ -r /dev/kvm ] && [ -w /dev/kvm ]; then - echo "āœ… KVM is readable and writable" - else - echo "āš ļø KVM permissions may not be optimal" - fi - - # Test KVM functionality - echo "Testing KVM functionality..." - if command -v kvm-ok >/dev/null 2>&1; then - if kvm-ok 2>/dev/null; then - echo "āœ… KVM is working properly" - else - echo "āš ļø KVM check failed, but continuing..." - fi - else - echo "ā„¹ļø kvm-ok command not available, testing manually..." - # Manual KVM test - if [ -r /dev/kvm ] && [ -w /dev/kvm ]; then - echo "āœ… KVM device is accessible" - fi - fi - - # Try to create udev rules (may fail in container, that's OK) - echo "Setting up udev rules..." - sudo mkdir -p /etc/udev/rules.d || echo "āš ļø Could not create udev directory" - echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules || echo "āš ļø Could not create udev rule" - - # Try to reload udev rules (will likely fail in container, that's OK) - sudo udevadm control --reload-rules || echo "ā„¹ļø udevadm control failed (expected in container)" - sudo udevadm trigger --name-match=kvm || echo "ā„¹ļø udevadm trigger failed (expected in container)" - - else - echo "āŒ KVM device not found at /dev/kvm" - echo "This will cause the emulator to run in software mode (slower)" - fi - - # Check system resources - echo "=== System Resources ===" - echo "Memory:" - free -h - echo "Disk space:" - df -h - echo "CPU cores:" - nproc - echo "KVM module loaded:" - lsmod | grep kvm || echo "KVM module not loaded (may be built-in)" - - echo "=== KVM Setup Complete ===" - - - name: 🦾 Cache gradle - uses: gradle/actions/setup-gradle@v3 - - - name: 🦾 Cache AVD - uses: actions/cache@v4 - id: avd-cache - with: - path: | - ~/.android/avd/* - ~/.android/adb* - key: avd-${{ matrix.api-level }} - - - name: šŸ”§ Prepare emulator utilities - run: | - # Create a helper script for emulator readiness checks - cat > /tmp/emulator_ready.sh << 'EOF' - #!/bin/bash - - echo "=== Emulator Readiness Check ===" - - # Wait for boot completion - echo "1. Checking boot completion..." - timeout 300 bash -c 'until adb shell getprop sys.boot_completed 2>/dev/null | grep -q "1"; do sleep 5; echo " Waiting for boot completion..."; done' - if [ $? -eq 0 ]; then - echo "āœ… Boot completed" - else - echo "āŒ Boot completion timeout" - echo "āš ļø Continuing anyway..." - fi - - # Wait for input service - echo "2. Checking input service..." - timeout 60 bash -c 'until adb shell service list 2>/dev/null | grep -q "input"; do sleep 2; echo " Waiting for input service..."; done' - if [ $? -eq 0 ]; then - echo "āœ… Input service ready" - else - echo "āŒ Input service timeout" - echo "āš ļø Continuing anyway..." - fi - - # Wait for package manager - echo "3. Checking package manager..." - timeout 60 bash -c 'until adb shell pm list packages 2>/dev/null >/dev/null; do sleep 2; echo " Waiting for package manager..."; done' - if [ $? -eq 0 ]; then - echo "āœ… Package manager ready" - else - echo "āŒ Package manager timeout" - echo "āš ļø Continuing anyway..." - fi - - # Wait for settings service - echo "4. Checking settings service..." - timeout 60 bash -c 'until adb shell settings get global window_animation_scale 2>/dev/null >/dev/null; do sleep 2; echo " Waiting for settings service..."; done' - if [ $? -eq 0 ]; then - echo "āœ… Settings service ready" - else - echo "āŒ Settings service timeout" - echo "āš ļø Continuing anyway..." - fi - - echo "āœ… Emulator readiness check completed" - EOF - - chmod +x /tmp/emulator_ready.sh - - - name: 🦾 Create AVD and generate snapshot for caching - if: steps.avd-cache.outputs.cache-hit != 'true' - uses: reactivecircus/android-emulator-runner@v2 - with: - api-level: ${{ matrix.api-level }} - force-avd-creation: false - # arch: ${{ matrix.arch }} - emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none -read-only -memory 1024 -no-snapshot-save - working-directory: ${{ github.workspace }} - disable-animations: false - script: | - echo "=== AVD Snapshot Generation ===" - - # Use the helper script to ensure emulator is fully ready - /tmp/emulator_ready.sh - - # Manually disable animations with error handling - echo "Disabling animations manually..." - adb shell settings put global window_animation_scale 0.0 || echo "Failed to disable window animations" - adb shell settings put global transition_animation_scale 0.0 || echo "Failed to disable transition animations" - adb shell settings put global animator_duration_scale 0.0 || echo "Failed to disable animator duration" - - echo "=== Checking emulator status before termination ===" - adb devices || echo "ADB devices check failed" - echo "=== AVD snapshot generation complete ===" - - - name: šŸš€ Integration tests on Android Emulator - timeout-minutes: 60 - uses: reactivecircus/android-emulator-runner@v2 - with: - api-level: ${{ matrix.api-level }} - force-avd-creation: false - emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none -read-only -memory 1024 -no-snapshot-save - disable-animations: false - working-directory: ${{ github.workspace }} - script: | - echo "=== Pre-test Environment Check ===" - pwd - ls -la integration_test_runner.sh || echo "integration_test_runner.sh not found" - - echo "=== Emulator Status Check ===" - adb devices - - echo "=== Emulator Info ===" - adb shell getprop ro.build.version.release || echo "Failed to get Android version" - adb shell getprop ro.product.model || echo "Failed to get device model" - - echo "=== Checking Emulator Performance ===" - adb shell cat /proc/cpuinfo | grep -i "model name" | head -1 || echo "Could not check CPU info" - adb shell cat /proc/meminfo | grep -i "memtotal" || echo "Could not check memory info" - - echo "=== Checking Emulator Process ===" - ps aux | grep emulator | grep -v grep || echo "Emulator process not found in ps output" - - echo "=== Making script executable ===" - chmod a+rx integration_test_runner.sh - - echo "=== Running integration tests ===" - - echo "Final emulator readiness check..." - /tmp/emulator_ready.sh || echo "Emulator readiness check failed, but continuing..." - - echo "Disabling animations for test performance..." - adb shell settings put global window_animation_scale 0.0 || echo "Failed to disable window animations" - adb shell settings put global transition_animation_scale 0.0 || echo "Failed to disable transition animations" - adb shell settings put global animator_duration_scale 0.0 || echo "Failed to disable animator duration" - - echo "Starting integration test runner..." - ./integration_test_runner.sh - TEST_EXIT_CODE=$? - - if [ $TEST_EXIT_CODE -eq 0 ]; then - echo "Integration tests completed successfully" - else - echo "Integration tests failed with exit code $TEST_EXIT_CODE" - fi - - - name: 🧹 Post-test cleanup - if: always() - run: | - echo "=== Post-test cleanup ===" - # Kill any remaining emulator processes - pkill -f emulator || echo "No emulator processes to kill" - # Kill any remaining adb processes - pkill -f adb || echo "No adb processes to kill" - # Wait for processes to terminate - sleep 2 - # Check for any remaining processes - ps aux | grep -E "(emulator|adb)" | grep -v grep || echo "No remaining emulator/adb processes" - echo "=== Cleanup complete ===" - - name: cleanup - run: rm -rf build/app/outputs/flutter-apk/test-apk/ diff --git a/.github/workflows/funds_integration_tests.yml b/.github/workflows/funds_integration_tests.yml new file mode 100644 index 000000000..bfa02a269 --- /dev/null +++ b/.github/workflows/funds_integration_tests.yml @@ -0,0 +1,64 @@ +name: Funds Integration Tests + +permissions: + contents: read + packages: write + +# This test is never triggered automatically as it moves real funds +on: + workflow_dispatch: + inputs: + flows: + description: "Which flows to run" + required: true + type: choice + options: + - all + - send + - swap + default: all + chains: + description: "Chains the send suites run on. auto discovers every funded chain, or a comma separated list of wallet type names" + required: true + type: string + default: auto + swap_from: + description: "Wallet type the swap deposits from. Empty takes the first funded chain" + required: false + type: string + default: "" + swap_to: + description: "Currency the swap receives, for example eth or btc. Empty leaves the swap page's own pairing" + required: false + type: string + default: "" + spend: + description: "Actually broadcast and create trades. Off runs the dry runs only" + required: true + type: boolean + default: false + record_screen: + description: "Record the emulator screen, uploaded GPG encrypted" + required: true + type: boolean + default: true + +concurrency: + group: funds-integration-tests + cancel-in-progress: false + +jobs: + funds-tests: + uses: ./.github/workflows/reusable-integration-test.yml + with: + environment: funds + ref: ${{ github.sha }} + suite_dir: integration_test/funds_suites + test_tier: all + write_funds_secrets: true + record_screen: ${{ inputs.record_screen }} + timeout_minutes: 120 + slack_notify: true + extra_dart_defines: "FLOWS=${{ inputs.flows }};CHAINS=${{ inputs.chains }};SPEND=${{ inputs.spend }};SWAP_FROM=${{ inputs.swap_from }};SWAP_RECEIVE=${{ inputs.swap_to }}" + secrets: inherit + diff --git a/.github/workflows/integration_tests.yml b/.github/workflows/integration_tests.yml new file mode 100644 index 000000000..a310bb13a --- /dev/null +++ b/.github/workflows/integration_tests.yml @@ -0,0 +1,28 @@ +name: Integration Tests + +permissions: + contents: read + packages: write + +on: + pull_request: + branches: [dev] + paths-ignore: + - "**.md" + - "docs/**" + workflow_dispatch: + +concurrency: + group: integration-tests-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + android-integration-tests: + if: github.event_name == 'workflow_dispatch' || github.event.pull_request.head.repo.fork == false + uses: ./.github/workflows/reusable-integration-test.yml + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} + suite_dir: integration_test/suites + test_tier: tier0 + slack_notify: true + secrets: inherit diff --git a/.github/workflows/pr_test_build_linux.yml b/.github/workflows/pr_test_build_linux.yml index 37c2bcf3f..80713b7d2 100644 --- a/.github/workflows/pr_test_build_linux.yml +++ b/.github/workflows/pr_test_build_linux.yml @@ -24,7 +24,6 @@ jobs: KEY_PASS: test@cake_wallet MONEROC_CACHE_DIR_ROOT: /opt/generic_cache BRANCH_NAME: ${{ github.head_ref || github.ref_name }} - DESKTOP_FORCE_MOBILE: Y volumes: - /opt/cw_cache_linux/root/.cache:/root/.cache - /opt/cw_cache_linux/root/.ccache:/root/.ccache @@ -61,14 +60,6 @@ jobs: git config --global --add safe.directory '*' git config --global user.email "ci@cakewallet.com" git config --global user.name "CakeWallet CI" - - - name: Get the full commit message - run: | - FULL_MESSAGE="$(git log -1 --pretty=%B)" - echo "message<> $GITHUB_ENV - echo "$FULL_MESSAGE" >> $GITHUB_ENV - echo "EOF" >> $GITHUB_ENV - - name: Add secrets run: | touch cw_bitcoin/lib/.secrets.g.dart @@ -143,54 +134,7 @@ jobs: path: ${{ github.workspace }}/build/linux/x64/release/cakewallet_linux.zip name: cakewallet_linux - - name: Prepare virtual desktop - if: ${{ contains(env.message, 'run tests') }} - run: | - nohup Xvfb :99 -screen 0 720x1280x16 & - echo DISPLAY=:99 | sudo tee -a $GITHUB_ENV - dbus-daemon --system --fork - nohup NetworkManager & - nohup ffmpeg -framerate 60 -video_size 720x1280 -f x11grab -i :99 -c:v libx264 -c:a aac /opt/screen_grab.mkv & - - # Note for people adding tests: - # - Tests are ran on Linux, with some things being mocked out. - # - Screen recording is being provided for the entire length of the test, you can download it in github articats. - # - Screen recordeding is encrypted, look at step "Stop screen recording, encrypt and upload", and add your key if you want - # Reason for encryption is the fact that we restore the wallet from seed, and we don't want to leak that, while there - # isn't much in those wallets anyway, we still wouldn't like to leak it to anyone who is able to access github. - - - name: Test [confirm_seeds_flow_test] - if: ${{ contains(env.message, 'run tests') }} - timeout-minutes: 20 - run: | - xmessage -timeout 30 "confirm_seeds_flow_test" & - rm -rf ~/.local/share/com.example.cake_wallet/ ~/Documents/cake_wallet/ ~/cake_wallet - exec timeout --signal=SIGKILL 900 flutter drive --driver=test_driver/integration_test.dart --target=integration_test/test_suites/confirm_seeds_flow_test.dart - - - name: Test [create_wallet_flow_test] - if: ${{ contains(env.message, 'run tests') }} - timeout-minutes: 20 - run: | - xmessage -timeout 30 "create_wallet_flow_test" & - rm -rf ~/.local/share/com.example.cake_wallet/ ~/Documents/cake_wallet/ ~/cake_wallet - exec timeout --signal=SIGKILL 900 flutter drive --driver=test_driver/integration_test.dart --target=integration_test/test_suites/create_wallet_flow_test.dart - - # - name: Test [exchange_flow_test] - # if: ${{ contains(env.message, 'run tests') }} - # timeout-minutes: 20 - # run: | - # xmessage -timeout 30 "exchange_flow_test" & - # rm -rf ~/.local/share/com.example.cake_wallet/ ~/Documents/cake_wallet/ ~/cake_wallet - # exec timeout --signal=SIGKILL 900 flutter drive --driver=test_driver/integration_test.dart --target=integration_test/test_suites/exchange_flow_test.dart - - - name: Test [restore_wallet_through_seeds_flow_test] - if: ${{ contains(env.message, 'run tests') }} - timeout-minutes: 20 - run: | - xmessage -timeout 30 "restore_wallet_through_seeds_flow_test" & - rm -rf ~/.local/share/com.example.cake_wallet/ ~/Documents/cake_wallet/ ~/cake_wallet - exec timeout --signal=SIGKILL 900 flutter drive --driver=test_driver/integration_test.dart --target=integration_test/test_suites/restore_wallet_through_seeds_flow_test.dart - + # Integration tests moved to integration_tests.yml - name: Test [cw_core] timeout-minutes: 15 run: cd cw_core && flutter test @@ -198,32 +142,3 @@ jobs: - name: Test [cw_monero] timeout-minutes: 15 run: cd cw_monero && flutter test - - - name: Stop screen recording, encrypt and upload - if: always() - run: | - if [[ ! -f "/opt/screen_grab.mkv" ]]; - then - exit 0; - fi - killall ffmpeg - sleep 5 - killall -9 ffmpeg || true - sleep 5 - # Feel free to add your own public key if you wish - gpg --keyserver hkps://keyserver.ubuntu.com --recv-keys 6B3199AD9B3D23B8 # konstantin@cakewallet.com - gpg --keyserver hkps://keyserver.ubuntu.com --recv-keys 35C8DBAFB8D9ACAC # cyjan@mrcyjanek.net - gpg --trust-model always --encrypt --output /opt/screen_grab.mkv.gpg \ - --recipient 6B3199AD9B3D23B8 \ - --recipient 35C8DBAFB8D9ACAC \ - /opt/screen_grab.mkv - rm /opt/screen_grab.mkv - mv /opt/screen_grab.mkv.gpg ./screen_grab.mkv.gpg - - - name: Upload Artifact to github - if: always() - continue-on-error: true - uses: actions/upload-artifact@v4 - with: - path: ${{ github.workspace }}/screen_grab.mkv.gpg - name: tests_screen_grab diff --git a/.github/workflows/reusable-build.yml b/.github/workflows/reusable-build.yml index 4dc447573..aed7afc8c 100644 --- a/.github/workflows/reusable-build.yml +++ b/.github/workflows/reusable-build.yml @@ -27,21 +27,15 @@ jobs: KEY_PASS: test@cake_wallet MONEROC_CACHE_DIR_ROOT: /opt/generic_cache BRANCH_NAME: ${{ github.head_ref || github.ref_name }} - ANDROID_AVD_HOME: /root/.android/avd volumes: - /opt/cw_cache_android/root/.cache:/root/.cache - - /opt/cw_cache_android/root/.android/avd/:/root/.android/avd - /opt/cw_cache_android/root/.ccache:/root/.ccache - /opt/cw_cache_android/root/.pub-cache/:/root/.pub-cache - /opt/cw_cache_android/root/.gradle/:/root/.gradle - /opt/cw_cache_android/root/.android/:/root/.android - /opt/cw_cache_android/root/go/pkg:/root/go/pkg - /opt/cw_cache_android/opt/generic_cache:/opt/generic_cache - - /dev/kvm:/dev/kvm - /var/run/docker.sock:/var/run/docker.sock - strategy: - matrix: - api-level: [29] steps: - name: Fix github actions messing up $HOME... diff --git a/.github/workflows/reusable-integration-test.yml b/.github/workflows/reusable-integration-test.yml new file mode 100644 index 000000000..bd8bafd30 --- /dev/null +++ b/.github/workflows/reusable-integration-test.yml @@ -0,0 +1,598 @@ +name: Reusable Integration Test Logic + +permissions: + contents: read + packages: write + +on: + workflow_call: + inputs: + ref: + required: true + type: string + suite_dir: + required: false + type: string + default: "integration_test/suites" + test_tier: + required: false + type: string + default: "tier0" + extra_dart_defines: + required: false + type: string + default: "" + environment: + required: false + type: string + default: "" + record_screen: + required: false + type: boolean + default: false + write_funds_secrets: + required: false + type: boolean + default: false + timeout_minutes: + required: false + type: number + default: 240 + slack_notify: + required: false + type: boolean + default: false + +defaults: + run: + shell: bash + +jobs: + integration-test: + runs-on: ubuntu-latest + environment: ${{ inputs.environment }} + timeout-minutes: ${{ inputs.timeout_minutes }} + env: + STORE_PASS: test@cake_wallet + KEY_PASS: test@cake_wallet + BRANCH_NAME: ${{ github.head_ref || github.ref_name }} + + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ inputs.ref }} + - name: Free disk space + run: | + sudo rm -rf /usr/share/dotnet /opt/ghc /usr/local/.ghcup /usr/local/lib/node_modules /opt/hostedtoolcache/CodeQL + sudo rm -rf /usr/share/swift /usr/local/share/powershell /usr/share/miniconda /opt/az /usr/local/graalvm + sudo docker image prune -af > /dev/null + df -h / + + - name: Enable KVM + 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 + + if [[ ! -c /dev/kvm ]]; then + echo "::error::/dev/kvm is not a character device, this runner has no usable KVM" + exit 1 + fi + + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: "17" + + - name: Restore flutter sdk + id: flutter-cache + uses: actions/cache/restore@v4 + with: + path: ~/flutter + key: flutter-sdk-3.41.9-${{ runner.os }} + + - name: Install Flutter + run: | + if [[ ! -d "$HOME/flutter/bin" ]]; then + git clone --branch 3.41.9 --depth 1 https://github.com/flutter/flutter.git "$HOME/flutter" + fi + echo "$HOME/flutter/bin" >> $GITHUB_PATH + export PATH="$PATH:$HOME/flutter/bin" + flutter --version + + - name: Save flutter sdk + if: steps.flutter-cache.outputs.cache-hit != 'true' + continue-on-error: true + uses: actions/cache/save@v4 + with: + path: ~/flutter + key: flutter-sdk-3.41.9-${{ runner.os }} + + - name: Setup gradle + uses: gradle/actions/setup-gradle@v3 + with: + cache-read-only: false + + - name: Restore pub packages + id: pub-cache + uses: actions/cache/restore@v4 + with: + path: ~/.pub-cache + key: pub-cache-${{ runner.os }}-${{ hashFiles('pubspec_base.yaml', 'cw_*/pubspec.yaml') }} + restore-keys: pub-cache-${{ runner.os }}- + + - name: Add rust android targets + run: | + rustup target add x86_64-linux-android aarch64-linux-android armv7-linux-androideabi i686-linux-android + + - name: Log in to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Add secrets + run: | + touch cw_bitcoin/lib/.secrets.g.dart + touch cw_evm/lib/.secrets.g.dart + touch cw_solana/lib/.secrets.g.dart + touch cw_core/lib/.secrets.g.dart + touch cw_nano/lib/.secrets.g.dart + touch cw_tron/lib/.secrets.g.dart + + echo "const polygonScanApiKey = '${{ secrets.POLYGON_SCAN_API_KEY }}';" >> cw_evm/lib/.secrets.g.dart + echo "const etherScanApiKey = '${{ secrets.ETHER_SCAN_API_KEY }}';" >> cw_evm/lib/.secrets.g.dart + echo "const moralisApiKey = '${{ secrets.MORALIS_API_KEY }}';" >> cw_evm/lib/.secrets.g.dart + echo "const nowNodesApiKey = '${{ secrets.EVM_NOWNODES_API_KEY }}';" >> cw_evm/lib/.secrets.g.dart + echo "const blinkApiKey = '${{ secrets.BLINK_API_KEY }}';" >> cw_evm/lib/.secrets.g.dart + echo "const ankrApiKey = '${{ secrets.ANKR_API_KEY }}';" >> cw_solana/lib/.secrets.g.dart + echo "const chainStackApiKey = '${{ secrets.CHAIN_STACK_API_KEY }}';" >> cw_solana/lib/.secrets.g.dart + echo "const moralisApiKey = '${{ secrets.MORALIS_API_KEY }}';" >> cw_solana/lib/.secrets.g.dart + + echo "const nano2ApiKey = '${{ secrets.NANO2_API_KEY }}';" >> cw_nano/lib/.secrets.g.dart + echo "const nanoNowNodesApiKey = '${{ secrets.NANO_NOW_NODES_API_KEY }}';" >> cw_nano/lib/.secrets.g.dart + + echo "const tronGridApiKey = '${{ secrets.TRON_GRID_API_KEY }}';" >> cw_tron/lib/.secrets.g.dart + echo "const tronNowNodesApiKey = '${{ secrets.TRON_NOW_NODES_API_KEY }}';" >> cw_tron/lib/.secrets.g.dart + + echo "const breezApiKey = '${{ secrets.BREEZ_API_KEY }}';" >> cw_bitcoin/lib/.secrets.g.dart + + echo "${{ secrets.MAIN_SECRETS_FILE }}" | base64 --decode > lib/.secrets.g.dart + - name: Add funded wallet secrets + if: ${{ inputs.write_funds_secrets }} + run: | + echo "${{ secrets.FUNDS_SECRETS_FILE }}" | base64 --decode > integration_test/core/funded_wallets.dart + + - name: Build dependencies utilizing ghcr cache + run: | + env \ + CW_DOCKER_REGISTRY=ghcr.io/cake-tech/cake_wallet \ + CW_DOCKER_USE_CLOUD=true \ + ./scripts/android/docker/build.sh + + - name: Reclaim the space the dependency images took + run: | + docker system prune -af --volumes > /dev/null + df -h / + + - name: Regenerate mweb bindings + run: | + git checkout -- cw_mweb/pubspec.yaml + rm -f cw_mweb/pubspec.lock + rm -rf cw_mweb/.dart_tool + + # Hosted runners ship libclang, only install when it is actually missing + if ! ldconfig -p | grep -q libclang; then + sudo apt-get install -y --no-install-recommends libclang-dev > /dev/null + fi + + if ! command -v protoc > /dev/null || + [[ ! -f /usr/include/google/protobuf/descriptor.proto ]]; then + sudo apt-get install -y --no-install-recommends protobuf-compiler libprotobuf-dev > /dev/null + fi + + pushd cw_mweb + flutter pub get + dart run ffigen --config ffigen_config.yaml + popd + + # Fail here in seconds rather than minutes into the gradle build + if ! grep -q "final class GoSlice" cw_mweb/lib/generated_bindings.g.dart; then + echo "::error::mweb bindings regenerated without class modifiers, ffigen resolved too old" + exit 1 + fi + + - name: Execute Build and Setup Commands + run: | + pushd scripts/android + source ./app_env.sh cakewallet + ./app_config.sh + popd + + - name: Install Flutter dependencies + run: | + flutter pub get + + - name: Save pub packages + if: steps.pub-cache.outputs.cache-hit != 'true' + continue-on-error: true + uses: actions/cache/save@v4 + with: + path: ~/.pub-cache + key: pub-cache-${{ runner.os }}-${{ hashFiles('pubspec_base.yaml', 'cw_*/pubspec.yaml') }} + + - name: Build generated code + run: | + flutter --version + ./model_generator.sh + + - name: Generate KeyStore + run: | + keytool -genkey -v -keystore android/app/key.jks -keyalg RSA -keysize 2048 -validity 10000 -alias testKey -noprompt -dname "CN=CakeWallet, OU=CakeWallet, O=CakeWallet, L=Florida, S=America, C=USA" -storepass $STORE_PASS -keypass $KEY_PASS + + - name: Generate key properties + run: | + dart run tool/generate_android_key_properties.dart keyAlias=testKey storeFile=key.jks storePassword=$STORE_PASS keyPassword=$KEY_PASS + + - name: Generate localization + run: | + dart run tool/generate_localization.dart + + - name: Compile graphics + run: | + ./compile_graphics.sh + + - name: Compile assets/images graphics + run: | + while read -r dir; do + dart run vector_graphics_compiler --input-dir "$dir" --out-dir "$dir" > /dev/null & + done < <(find assets/images -type d) + wait + + - name: Rename app + run: | + sanitized_branch_name=${BRANCH_NAME#origin/} # Remove 'origin/' prefix if it exists + sanitized_branch_name=${sanitized_branch_name:0:16} # Take only the first 16 characters + sanitized_branch_name=$(echo "$sanitized_branch_name" | tr '[:upper:]' '[:lower:]') # Convert to lowercase + sanitized_branch_name=$(echo "$sanitized_branch_name" | sed 's/[^a-z0-9]//g') # Remove all special characters + + echo -e "id=com.cakewallet.test_${sanitized_branch_name}\nname=${BRANCH_NAME}" > android/app.properties + + - name: Prime test build + env: + SUITE_DIR_INPUT: ${{ inputs.suite_dir }} + TEST_TIER_INPUT: ${{ inputs.test_tier }} + run: | + search_dir="$SUITE_DIR_INPUT" + if [[ "$TEST_TIER_INPUT" != "all" ]]; then + search_dir="$search_dir/$TEST_TIER_INPUT" + fi + first_suite=$(find "$search_dir" -name "*_test.dart" -type f | sort | head -1) + echo "Priming build with $first_suite" + flutter build apk --debug --target-platform android-x64 --target="$first_suite" --dart-define=CI_BUILD=true + + - name: Cap the gradle heap for the test phase + run: | + sed -i 's/^org.gradle.jvmargs=.*/org.gradle.jvmargs=-Xmx3072M/' android/gradle.properties + grep '^org.gradle.jvmargs' android/gradle.properties + pkill -f GradleDaemon || echo "No gradle daemon to stop" + free -h || true + df -h / || true + + - name: Integration tests on Android Emulator + uses: reactivecircus/android-emulator-runner@v2 + env: + RECORD_SCREEN: ${{ inputs.record_screen }} + ENCRYPT_LOGS: ${{ inputs.write_funds_secrets }} + SUITE_DIR_INPUT: ${{ inputs.suite_dir }} + EXTRA_DART_DEFINES_INPUT: ${{ inputs.extra_dart_defines }} + with: + api-level: 29 + arch: x86_64 + profile: pixel_5 + force-avd-creation: false + emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none -read-only -memory 3072 -cores 4 -no-snapshot-save + disable-animations: false + working-directory: ${{ github.workspace }} + script: bash scripts/ci/run_integration_tests.sh ${{ inputs.test_tier }} + + - name: Upload test logs + if: always() + continue-on-error: true + uses: actions/upload-artifact@v4 + with: + name: integration-test-logs + path: | + ${{ github.workspace }}/${{ inputs.test_tier }}_run.log + ${{ github.workspace }}/${{ inputs.test_tier }}_run.log.gpg + ${{ github.workspace }}/${{ inputs.test_tier }}_logcat.txt + ${{ github.workspace }}/${{ inputs.test_tier }}_logcat.txt.gpg + ${{ github.workspace }}/recordings/*.gpg + + - name: Integration tests on Android Emulator (tier1, non-blocking) + if: ${{ inputs.test_tier == 'tier0' }} + continue-on-error: true + uses: reactivecircus/android-emulator-runner@v2 + env: + RECORD_SCREEN: ${{ inputs.record_screen }} + ENCRYPT_LOGS: ${{ inputs.write_funds_secrets }} + SUITE_DIR_INPUT: ${{ inputs.suite_dir }} + EXTRA_DART_DEFINES_INPUT: ${{ inputs.extra_dart_defines }} + with: + api-level: 29 + arch: x86_64 + profile: pixel_5 + force-avd-creation: false + emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none -read-only -memory 3072 -cores 4 -no-snapshot-save + disable-animations: false + working-directory: ${{ github.workspace }} + script: bash scripts/ci/run_integration_tests.sh tier1 + + - name: Upload tier1 logs + if: ${{ always() && inputs.test_tier == 'tier0' }} + continue-on-error: true + uses: actions/upload-artifact@v4 + with: + name: integration-test-logs-tier1 + path: | + ${{ github.workspace }}/tier1_run.log + ${{ github.workspace }}/tier1_run.log.gpg + ${{ github.workspace }}/tier1_logcat.txt + ${{ github.workspace }}/tier1_logcat.txt.gpg + + # Superseded runs are cancelled on purpose, reporting those would be noise + - name: Report the run to slack + if: ${{ !cancelled() && inputs.slack_notify }} + continue-on-error: true + env: + SLACK_TOKEN: ${{ secrets.SLACK_TESTS_TOKEN }} + SLACK_CHANNEL: ${{ secrets.SLACK_TESTS_CHANNEL }} + BRANCH: ${{ github.head_ref || github.ref_name }} + PR_NUMBER: ${{ github.event.pull_request.number }} + PR_TITLE: ${{ github.event.pull_request.title }} + COMMIT: ${{ github.event.pull_request.head.sha || github.sha }} + ACTOR: ${{ github.actor }} + REPO_URL: ${{ github.server_url }}/${{ github.repository }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + DART_DEFINES: ${{ inputs.extra_dart_defines }} + run: | + if [[ -z "$SLACK_TOKEN" || -z "$SLACK_CHANNEL" ]]; then + echo "::notice::No slack token or channel configured, skipping the report" + exit 0 + fi + + fmt() { + printf '%dm %02ds' $(( $1 / 60 )) $(( $1 % 60 )) + } + + field() { + awk -F= -v key="$2" '$1 == key { print $2; exit }' "$1" + } + + tier_line() { + local file="$1" label="$2" note="$3" + + if [[ ! -f "$file" ]]; then + printf '*%s*\n%s' "$label" "$note" + return + fi + + printf '*%s*\n%s/%s passed in %s' "$label" \ + "$(field "$file" passed)" "$(field "$file" total)" \ + "$(fmt "$(field "$file" duration)")" + } + + # For scenarios where the suite never ran because of driver issues, + # we can't call it a failing test, so we just log it + failure_lines() { + local file="$1" label="$2" + + [[ -f "$file" ]] || return 0 + + while IFS= read -r entry; do + [[ -n "$entry" ]] || continue + + local rest="${entry#*|}" + local name="${entry%%|*}" + local duration="${rest%%|*}" + local reason="${rest#*|}" + + if [[ "$reason" == "void" ]]; then + printf '\n• `%s` (%s) _%s, driver never attached, the suite did not run_' \ + "$name" "$(fmt "$duration")" "$label" + else + printf '\n• `%s` (%s) _%s_' "$name" "$(fmt "$duration")" "$label" + fi + done < <(sed -n 's/^fail=//p' "$file") + } + + # Adding the results for the funds suites to the report too, so we know if we need to topup + chain_lines() { + local status_wanted="$1" icon="$2" file + local seen="" + + for file in *_summary.txt; do + [[ -f "$file" ]] || continue + + while IFS= read -r entry; do + [[ -n "$entry" ]] || continue + + local chain="${entry%%|*}" + local rest="${entry#*|}" + local status="${rest%%|*}" + local detail="${rest#*|}" + + [[ "$status" == "$status_wanted" ]] || continue + [[ "$seen" == *"[$chain]"* ]] && continue + seen="$seen[$chain]" + + printf '\n%s *%s* _%s_' "$icon" "$chain" "$detail" + done < <(sed -n 's/^chain=//p' "$file") + done + } + + pass_names() { + local file="$1" label="$2" + + [[ -f "$file" ]] || return 0 + + printf '*%s* (%s)' "$label" "$(field "$file" passed)" + + while IFS= read -r entry; do + [[ -n "$entry" ]] || continue + printf '\n• %s _%s_' "${entry%%|*}" "$(fmt "${entry##*|}")" + done < <(sed -n 's/^pass=//p' "$file") + } + + tier0_failed=1 + tier1_failed=0 + total_passed=0 + + if [[ -f all_summary.txt ]]; then + tier0_failed=$(field all_summary.txt failed) + else + [[ -f tier0_summary.txt ]] && tier0_failed=$(field tier0_summary.txt failed) + [[ -f tier1_summary.txt ]] && tier1_failed=$(field tier1_summary.txt failed) + fi + + for file in tier0_summary.txt tier1_summary.txt; do + [[ -f "$file" ]] || continue + total_passed=$(( total_passed + $(field "$file" passed) )) + done + + if (( tier0_failed > 0 )); then + headline=":x: Integration tests failed" + elif (( tier1_failed > 0 )); then + headline=":warning: Gate passed, tier1 has failures" + else + headline=":white_check_mark: Integration tests passed" + fi + + if (( tier0_failed > 0 )); then + tier1_note="_skipped, tier0 failed_" + else + tier1_note="_did not run_" + fi + + where="<$REPO_URL/tree/$BRANCH|$BRANCH>" + if [[ -n "$PR_NUMBER" ]]; then + where="$where Ā· <$REPO_URL/pull/$PR_NUMBER|#$PR_NUMBER ${PR_TITLE:-}>" + fi + + failures=$(printf '%s%s%s' \ + "$(failure_lines tier0_summary.txt tier0)" \ + "$(failure_lines tier1_summary.txt tier1)" \ + "$(failure_lines all_summary.txt suites)") + + if [[ -f all_summary.txt ]]; then + primary=$(tier_line all_summary.txt suites '') + secondary="" + else + primary=$(tier_line tier0_summary.txt tier0 '_did not run_') + secondary=$(tier_line tier1_summary.txt tier1 "$tier1_note") + fi + + chains=$(printf '%s%s%s%s' \ + "$(chain_lines ok ':white_check_mark:')" \ + "$(chain_lines empty ':moneybag:')" \ + "$(chain_lines waiting ':hourglass:')" \ + "$(chain_lines failed ':x:')") + + needs_funding=$(chain_lines empty ':moneybag:') + too_slow=$(chain_lines waiting ':hourglass:') + + blocks=$(jq -n \ + --arg headline "$headline" \ + --arg where "$where" \ + --arg primary "$primary" \ + --arg secondary "$secondary" \ + --arg failures "$failures" \ + --arg chains "$chains" \ + --arg funding "$needs_funding" \ + --arg slow "$too_slow" \ + --arg defines "$(printf '%s' "$DART_DEFINES" | tr ';' ' ')" \ + --arg commit "${COMMIT:0:8}" \ + --arg actor "$ACTOR" \ + --arg repo "$REPO_URL" \ + --arg run "$RUN_URL" \ + '[ + { type: "header", text: { type: "plain_text", text: $headline, emoji: true } }, + { type: "section", text: { type: "mrkdwn", text: $where } }, + { type: "section", fields: ( + [ { type: "mrkdwn", text: $primary } ] + + (if $secondary == "" then [] else + [ { type: "mrkdwn", text: $secondary } ] end) + ) } + ] + + (if $failures == "" then [] else + [ { type: "section", text: { type: "mrkdwn", + text: ("*Did not pass*" + $failures) } } ] + end) + + (if $chains == "" then [] else + [ { type: "divider" }, + { type: "section", text: { type: "mrkdwn", + text: ("*Chains*" + $chains) } } ] + end) + + (if $funding == "" then [] else + [ { type: "context", elements: [ { type: "mrkdwn", + text: ":moneybag: The chains above could not cover a send. Top those wallets up and re run the funds workflow, nothing there needs debugging :)." } ] } ] + end) + + (if $slow == "" then [] else + [ { type: "context", elements: [ { type: "mrkdwn", + text: ":hourglass: Still rescanning when the clock ran out, so nothing was proven either way and the run is not red for them." } ] } ] + end) + + [ + { type: "context", elements: [ + { type: "mrkdwn", + text: ("<" + $repo + "/commit/" + $commit + "|" + $commit + "> Ā· " + $actor + + (if $defines == "" then "" else " Ā· `" + $defines + "`" end)) } + ] }, + { type: "actions", elements: [ + { type: "button", url: $run, + text: { type: "plain_text", text: "View run", emoji: false } } + ] } + ]') + + response=$(curl -sS -X POST https://slack.com/api/chat.postMessage \ + -H "Authorization: Bearer $SLACK_TOKEN" \ + -H "Content-type: application/json; charset=utf-8" \ + --data "$(jq -n --arg channel "$SLACK_CHANNEL" --arg text "$headline" \ + --argjson blocks "$blocks" \ + '{channel: $channel, text: $text, blocks: $blocks}')") + + if [[ "$(jq -r '.ok' <<< "$response")" != "true" ]]; then + error=$(jq -r '.error // "unknown"' <<< "$response") + needed=$(jq -r '.needed // empty' <<< "$response") + + if [[ -n "$needed" ]]; then + echo "::warning::Slack rejected the report: $error, it wants the $needed scope" + else + echo "::warning::Slack rejected the report: $error" + fi + + exit 0 + fi + + thread_ts=$(jq -r '.ts // empty' <<< "$response") + + if [[ -z "$thread_ts" ]] || (( total_passed == 0 )); then + exit 0 + fi + + # So that the channel is not clutter (and uglyyyy), we put everything that passed in the thread + # and the short version is the one that shows up in the channel + passed=$(printf '%s\n\n%s' \ + "$(pass_names tier0_summary.txt tier0)" "$(pass_names tier1_summary.txt tier1)") + + curl -sS -X POST https://slack.com/api/chat.postMessage \ + -H "Authorization: Bearer $SLACK_TOKEN" \ + -H "Content-type: application/json; charset=utf-8" \ + --data "$(jq -n --arg channel "$SLACK_CHANNEL" --arg ts "$thread_ts" --arg text "$passed" \ + '{channel: $channel, thread_ts: $ts, text: $text}')" > /dev/null + + - name: Post-test cleanup + if: always() + run: | + pkill -f emulator || echo "No emulator processes to kill" + pkill -f adb || echo "No adb processes to kill" diff --git a/docs/INTEGRATION_TESTS.md b/docs/INTEGRATION_TESTS.md new file mode 100644 index 000000000..d405cf578 --- /dev/null +++ b/docs/INTEGRATION_TESTS.md @@ -0,0 +1,306 @@ +# Integration tests + +End to end tests that drive the real app on a device. They run on an Android emulator in CI +for every pull request into `dev`, and the funds suites run manually before releases. + +## Architecture + +Four layers, each one only talks to the layer below it: + +``` +suites/ the runnable tests, one file per user journey + tier0/ offline capable, no network assertions, the hard PR gate + tier1/ needs live network (nodes, swap providers), non blocking on PRs +funds_suites/ moves real funds, only ever run by the manual funds workflow +flows/ multi screen journeys shared between suites (onboarding, auth, wallet list) +robots/ one class per screen, knows that screen's keys and assertions +core/ the harness: BaseRobot primitives, AppLauncher, TestConfig, TestWallets +``` + +New robots extend `BaseRobot`. The older onboarding robots still hold a `CommonTestCases` +instead, which is the same idea with a weaker waiting story, migrate one over whenever you +touch it. + +Supporting pieces: + +- `test_driver/integration_test.dart` is the standard flutter drive driver. +- `integration_test_runner.sh` discovers and runs suites, see Running locally. +- `.github/workflows/integration_tests.yml` is the PR gate, + `reusable-integration-test.yml` holds the shared build and emulator pipeline. + +## Running locally + +Prerequisites, one time: + +1. A generated `lib/.secrets.g.dart` with the `*TestWalletSeeds` values populated. +2. App config for the cakewallet flavor: + `cd scripts/android && source ./app_env.sh cakewallet && ./app_config.sh` +3. Rename the app, every run wipes its data between suites: + + ``` + printf 'id=com.cakewallet.test_local\nname=local\n' > android/app.properties + ``` + + Step 2 writes the real `com.cakewallet.cake_wallet` id, and clearing that package takes + your own wallets with it. CI does this same rename after app_config for the same reason. + The runner refuses to clear a package that is not named for testing. +4. Compiled vector assets, both passes: + + ``` + ./compile_graphics.sh + while read -r dir; do + dart run vector_graphics_compiler --input-dir "$dir" --out-dir "$dir" + done < <(find assets/images -type d) + ``` + + `compile_graphics.sh` does not cover `assets/images`, and those `.svg.vec` files are + neither committed nor ignored, so a fresh checkout has none of them. Without this the + app throws "Unable to load asset" on the welcome screen and the first suite fails. +5. Native libs in `android/app/src/main/jniLibs/` for monero, wownero and zano + (prebuilts from the pinned monero_c release). +6. `~/.cargo/bin` on PATH so gradle can build the breez rust crates, and `protoc` installed + (`brew install protobuf`), the spark crate generates its protobuf bindings at build time. + +If the build sits at 0% CPU for minutes, close Android Studio or stop its Gradle daemon, +two daemons on this project deadlock on the same locks. + +Re-run steps 2 to 4 after every merge from dev, then `flutter pub get` at the root and the +codegen for `lib/`. The chain proxies in `lib//.dart` are generated and +gitignored, so a merge that adds a call like `bitcoin!.hasSelectedLightning` leaves them +behind and the build fails on a method that looks like it should exist. Step 2 also +rewrites `android/app.properties` with the real app id, so redo step 3 alongside it, and it +finishes by rewriting `pubspec.yaml` after its own last pub get, which is why the root pub +get has to come after it. Skipping step 4 shows up as "Unable to load asset" on whichever +screen dev added an icon to, one suite in, rather than at build time. + +Run one suite against a booted emulator: + +``` +flutter drive --driver=test_driver/integration_test.dart \ + --target=integration_test/suites/tier0/onboarding_create_test.dart \ + --dart-define=CI_BUILD=true -d +``` + +Run a whole tier through the runner: + +``` +TEST_TIER=tier0 FLUTTER_DEVICE= ./integration_test_runner.sh +``` + +`SUITE_DIR` also takes a single file, which is what you want while iterating on one suite: + +```bash +SUITE_DIR=integration_test/suites/tier0/fiat_currency_test.dart ./integration_test_runner.sh +``` + +Runner knobs (environment variables): `SUITE_DIR`, `TEST_TIER` (tier0, tier1, all), +`PLATFORM` (android, linux, macos, auto), `FLUTTER_DEVICE`, `RETRY_COUNT`, +`EXTRA_DART_DEFINES`, `REMOVE_DATA_DIRECTORY=N` to keep app data between suites, +`REMOVE_DESKTOP_DATA=Y` to let a desktop run wipe the wallet directories it shares with the +installed app, `VOID_GRACE` for how long a driver that cannot attach is given to recover. + +Test knobs (dart defines): `TEST_WALLET_TYPES=all` runs every available wallet type, +a comma separated list of type names runs just those, unset runs the representative set +(solana, ethereum, bitcoin, monero). + +## Which tier does my test go in + +- Creates wallets, navigates screens, checks local state: **tier0**. +- Needs a node, a provider API or any live network response: **tier1**. +- Signs and broadcasts a real transaction: **funds_suites**, never anywhere else. + +tier0 blocks merges. tier1 runs on every PR but reports without blocking until it has a +stable history. funds_suites run only from the manual funds workflow. + +## What is covered today + +Twenty seven suites. Add a line here when you add one. + +### tier0, no network needed + +| Suite | What it proves | +| --- | --- | +| `onboarding_create_test` | A wallet can be created for every configured type, and the app lands on the dashboard with that wallet open | +| `onboarding_restore_test` | A wallet restores from seed for every configured type, and the restored wallet reports the seed it was given | +| `invalid_seed_test` | A seed the wallet cannot parse leaves the user on the restore screen with nothing created | +| `duplicate_wallet_name_test` | A name already taken is refused instead of quietly overwriting the wallet holding it | +| `wallet_group_test` | A wallet added to an existing seed shares that seed, and both wallets stay reachable through the group | +| `wallet_rename_test` | A wallet can be renamed, and cannot be renamed onto a name another wallet already holds | +| `wallet_delete_test` | Deleting a wallet takes it off the list and out of storage, and leaves the others alone | +| `receive_address_test` | The receive sheet shows an address the opened wallet owns, not a leftover from the previous one | +| `seed_confirmation_test` | The seed and keys the app displays are the ones the opened wallet holds | +| `show_keys_auth_test` | The seed and keys page turns away a wrong pin and opens for the right one | +| `change_pin_test` | A changed pin is the one that opens what the old pin used to guard | +| `send_validation_test` | The send screen refuses an empty address, a malformed one, an empty amount and more than the balance | +| `address_book_test` | A saved contact keeps the address it was given | +| `send_from_address_book_test` | A contact picked on the send screen fills in the address that was saved for it | +| `settings_nav_test` | Every settings row opens its page and backs out of it | +| `language_test` | Changing the language changes what the settings screen shows | +| `fiat_currency_test` | Turning the fiat api off hides the currency setting, turning it back on returns it and the currency can be changed | + +### tier1, needs a node or a provider + +| Suite | What it proves | +| --- | --- | +| `sync_status_test` | A restored wallet reaches a node and starts syncing rather than sitting at connecting | +| `node_switching_test` | Confirming another node repoints the wallet at it | +| `transaction_history_test` | A wallet with known transactions renders them, both in the home preview and the full list | +| `transaction_details_test` | Tapping a transaction opens the details of that transaction and not another one | +| `swap_quote_test` | The swap sheet reaches a provider and returns a real quote, no trade is created | +| `monero_legacy_seed_test` | A 25 word monero seed restores through the legacy path, which takes a seed type and a restore height the polyseed path never asks for | + +### funds_suites, manual workflow only + +| Suite | What it proves | +| --- | --- | +| `send_dry_run_test` | Every funded chain builds and signs a transaction, then throws it away without broadcasting | +| `send_funds_test` | A real self transaction is broadcast on every funded chain | +| `swap_dry_run_test` | A real deposit is priced end to end, stopping before the trade is created | +| `swap_funds_test` | A swap is created and its deposit broadcast | + +The two dry runs need funded seeds because a quote and a signed transaction both depend on a +real balance, but neither spends anything. `SPEND=true` is what lets the two that do spend +past their guard. + +## Adding a test for your feature + +1. Give the widgets your test touches stable keys, inline in the widget: + `key: ValueKey('__key')`. Keys go on the widget the test taps or reads, + never on AnimatedSwitcher children where the key is the animation identity. +2. Create or extend the robot for the screen in `robots/`. One robot per screen, it + extends `BaseRobot` and implements `isDisplayed()`. Robots own every finder for their + screen, suites never call `find` directly. +3. If the journey spans several screens, add a flow to `flows/` composing the robots. +4. Copy `integration_test/templates/example_feature_test.dart.example` into the right + tier directory, rename it to `_test.dart` and fill it in. The runner only + picks up files ending in `_test.dart`. +5. Run it twice locally against an emulator before pushing. +6. Add it to the table above. + +## Harness rules + +- Suites use `integrationTest` from `core/app_launcher.dart`, not `testWidgets`. It contains + background async errors from app networking that would otherwise fail the test, awaited + assertion failures still fail the test normally. +- No `sleep` or fixed delays, use `pumpUntilFound` and `pumpUntil` with a timeout. +- No `pumpAndSettle` in new code, screens with endless animations never settle, + use the bounded `settle()` from BaseRobot. +- No `find.text` on localized strings, keys only. Wallet names and addresses are data, + matching on those is fine. +- `.secrets.g.dart` is only imported by `core/test_wallets.dart`. +- Every suite starts from a fresh install, the runner wipes app data between suites. +- Reading a view model through the page widget for a state assertion is fine, driving + the app through view models instead of the UI is not. + +## Secrets + +Test wallet seeds and receive addresses live in `lib/.secrets.g.dart` under names like +`solanaTestWalletSeeds`, defined in `tool/utils/secret_key.dart` and reachable through +`TestWallets`. CI injects the real values from the `MAIN_SECRETS_FILE` repository secret. + +Adding a new test secret means adding it in `tool/utils/secret_key.dart`, mapping it in +`TestWallets` and asking the team lead to add the value to the CI secret. + +The slack report uses two more repository secrets, `SLACK_TESTS_TOKEN` and +`SLACK_TESTS_CHANNEL`, which is a channel id rather than a webhook. Both the PR gate and +the funds workflow report through them, so every test result lands in one channel. + +That is a separate slack app from the one behind `SLACK_APP_TOKEN`, which uploads apks to +the builds channel. They are split so each token carries only what it needs, `chat:write` +for this one and the file scopes for that one, and so the name on a message says which of +the two sent it. A post coming back `missing_scope` means the app is short the +`chat:write` scope or was never reinstalled after it was added. + +## Funds suites + +`funds_suites/` restore funded wallets and move real funds: a small self send on every +funded chain, and one real swap with its deposit broadcast. They only run from the manual +`Funds Integration Tests` workflow, which the team lead dispatches from the Actions tab +before a release. The job runs in the `funds` environment, so add required reviewers to that +environment under Settings, Environments, or a dispatch still starts without an approval. + +Funded seeds live in the `FUNDS_SECRETS_FILE` secret, a base64 encoded replacement for +`integration_test/core/funded_wallets.dart` mapping wallet type names to that chain's +funded wallets, at least two per chain. A wallet with a passphrase carries it and the restore +flow ticks the passphrase box and enters it. A wallet without one leaves it out: + +```dart +import "funded_wallet.dart"; + +const Map> fundedWallets = { + "solana": [ + FundedWallet(seed: "first wallet seed words ...", passphrase: "first passphrase"), + FundedWallet(seed: "second wallet seed words ...", passphrase: "second passphrase"), + ], + "ethereum": [ + FundedWallet(seed: "seed words ..."), + ], +}; +``` + +A wrong passphrase restores an empty derivation, so it shows up the same way a drained +wallet does, as "no spendable balance". + +The suites restore a chain's funded wallets one by one and use the first that shows a +spendable balance, a wallet that finishes syncing while still empty counts as drained. +A chain where every funded wallet is empty fails with a message asking for a top up. + +The checked in default is an empty map and must stay empty. Only the funds workflow turns +on `write_funds_secrets`, and that input is the one thing keeping funded seeds out of a PR +build now that no environment scopes the secret. In auto mode the suites discover every +chain present in the map, so funding a new chain only means adding its entry to the +secret. The dispatch inputs narrow a run: `flows` picks send, swap or both, `chains` lists +the chains the send suites run on, `swap_from` picks the wallet type the swap deposits from +(empty takes the first funded chain) and `swap_to` the currency it receives (empty leaves +the swap page's own pairing, which is XMR for everything but a monero wallet). Whatever +`swap_to` names must have a fixture address in `TestWallets.receiveAddressFor`, that is +where the payout goes. Per chain send amounts are tuned in `TestConfig._fundsSendAmounts`. +The swap suite enters an amount just above the provider minimum, the deposit wallet's +balance has to cover that minimum plus fees. + +## How CI runs them + +The PR gate runs on GitHub hosted runners, the self-hosted android builders have no +usable KVM so the emulator cannot start there. Moving back is a one line change to +`runs-on` in `reusable-integration-test.yml` once a KVM capable builder exists. + +tier0 and tier1 each get their own emulator step and upload their logs separately, so a +runner that dies during tier1 cannot take the gate's own results with it. The gradle heap +is capped before the test phase because the daemon and the emulator together were +exhausting the runner. + +Every run posts to slack. The message names each tier's counts, its duration and any suite +that failed, and the full list of what passed goes in a reply on the same message so the +channel keeps the short version. A suite that failed because the driver never attached is +labelled as such rather than reported as a failing test, since in that case the suite never +ran at all. It needs `SLACK_TESTS_TOKEN` and `SLACK_TESTS_CHANNEL`, and +without either the step logs a notice and skips rather than failing the gate. Cancelled +runs stay quiet, since superseded runs are cancelled on purpose. The report is built from +the `SUMMARY_FILE` each tier writes, so anything the runner counts is available to it. + +Two environment quirks are worth knowing when a build fails in CI but not locally: the +deps docker image regenerates the mweb ffi bindings with an ffigen too old for the current +compiler and its rsync overwrites `cw_mweb/pubspec.yaml`, so the workflow restores that +file and regenerates the bindings itself. And `assets/images` svgs need compiling to +`.vec`, which `compile_graphics.sh` does not cover. + +## Flakiness playbook + +- A timeout in `pumpUntilFound` throws with the finder description. Diagnose through the + `integration-test-logs` artifact in CI, it holds the run log and a final logcat dump. + The timeout also takes a screenshot, it lands in `build/integration_test_screenshots/` + on the machine that ran the suite, named after the robot and the helper that timed out. + CI does not upload them. +- The app's own error handling ignores missing `.svg.vec` assets by design, the harness + filters those too. Any other FlutterError fails the test, that is intentional. +- Monero and wownero load native libraries on first open, give their steps generous + timeouts rather than retries. +- A driver that cannot attach is watched rather than waited out. Once flutter drive says + it is struggling, the attempt gets `VOID_GRACE` seconds (60 by default) to come back and + is then ended so the retry can start, instead of sitting there for the rest of + `TEST_TIMEOUT`. Run 31364715120 lost 20 minutes to two attempts that did exactly that. + 60 is well clear of a healthy attach, which took under a second on all 19 attempts that + worked in that same run, against 5 seconds for the warning to appear on the two that + did not. +- A suite that fails once in CI retries once with wiped app data. A red gate means the + same suite failed twice in a row. diff --git a/integration_test/components/common_test_cases.dart b/integration_test/components/common_test_cases.dart deleted file mode 100644 index c8f9843a6..000000000 --- a/integration_test/components/common_test_cases.dart +++ /dev/null @@ -1,260 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; - -class CommonTestCases { - WidgetTester tester; - CommonTestCases(this.tester); - - Future isSpecificPage() async { - await tester.pumpAndSettle(); - hasType(); - } - - Future tapItemByKey( - String key, { - bool shouldPumpAndSettle = true, - int pumpDuration = 100, - }) async { - await tester.pump(Duration(milliseconds: 500)); - - final widgetFinder = find.byKey(ValueKey(key)); - - expect(tester.any(widgetFinder), true, reason: 'Widget with key "$key" should be visible'); - - final widget = widgetFinder.first; - await tester.tap(widget, warnIfMissed: false); - shouldPumpAndSettle - ? await tester.pumpAndSettle(Duration(milliseconds: pumpDuration)) - : await tester.pump(); - } - - Future tapItemByFinder(Finder finder, {bool shouldPumpAndSettle = true}) async { - await tester.tap(finder); - shouldPumpAndSettle ? await tester.pumpAndSettle() : await tester.pump(); - } - - void hasText(String text, {bool hasWidget = true}) { - final textWidget = find.text(text); - expect(textWidget, hasWidget ? findsOneWidget : findsNothing); - } - - void hasTextAtLestOnce(String text, {bool hasWidget = true}) { - final textWidget = find.text(text); - expect(textWidget, hasWidget ? findsAny : findsNothing); - } - - void hasType() { - final typeWidget = find.byType(T); - expect(typeWidget, findsOneWidget); - } - - bool isKeyPresent(String key) { - final typeWidget = find.byKey(ValueKey(key)); - return typeWidget.tryEvaluate(); - } - - void hasValueKey(String key) { - final typeWidget = find.byKey(ValueKey(key)); - expect(typeWidget, findsOneWidget); - } - - Future swipePage({bool swipeRight = true}) async { - await tester.drag(find.byType(PageView), Offset(swipeRight ? -300 : 300, 0)); - await tester.pumpAndSettle(); - } - - Future swipeByPageKey({required String key, bool swipeRight = true}) async { - await tester.drag(find.byKey(ValueKey(key)), Offset(swipeRight ? -300 : 300, 0)); - await tester.pumpAndSettle(); - } - - Future goBack() async { - tester.printToConsole('Routing back to previous screen'); - final NavigatorState navigator = tester.state(find.byType(Navigator)); - navigator.pop(); - await tester.pumpAndSettle(); - } - - Future startGesture(String key, Offset gestureOffset) async { - await tester.pumpAndSettle(); - - final hasKey = isKeyPresent(key); - - tester.printToConsole("Has gestureKey: $hasKey"); - - if (!hasKey) return; - - final gesture = await tester.startGesture(tester.getCenter(find.byKey(ValueKey(key)))); - - // Drag to the left - await gesture.moveBy(gestureOffset); - - // End the gesture - await gesture.up(); - - await tester.pump(); - } - - Future dragUntilVisible( - String childKey, - String parentKey, { - int maxScrolls = 100, - double scrollStep = 50.0, - int maxReverseScrolls = 50, - }) async { - await tester.pumpAndSettle(); - - final itemFinder = find.byKey(ValueKey(childKey)); - final listFinder = find.byKey(ValueKey(parentKey)); - - // Check if the widget is already in the widget tree - if (tester.any(itemFinder)) { - // Widget is already built and in the tree - tester.printToConsole('Child is already present'); - return; - } - - // Find the Scrollable associated with the Parent Ad - final scrollableFinder = find.descendant( - of: listFinder, - matching: find.byType(Scrollable), - ); - - // Ensure that the Scrollable is found - expect( - scrollableFinder, - findsOneWidget, - reason: 'Scrollable descendant of the Parent Widget not found.', - ); - - // Get the initial scroll position - final scrollableState = tester.state(scrollableFinder); - double previousScrollPosition = scrollableState.position.pixels; - bool scrollDown = true; - bool reversedDirection = false; - bool found = false; - - int reverseScrollCount = 0; - - for (int scrolls = 0; scrolls < maxScrolls; scrolls++) { - await tester.pumpAndSettle(); - - // Check if the widget is visible - if (tester.any(itemFinder)) { - found = true; - break; - } - - // Log current state for debugging - tester.printToConsole('Scrolling ${scrollDown ? 'down' : 'up'}, attempt $scrolls'); - - // Stop if reverse scroll limit is exceeded - if (!scrollDown && reverseScrollCount >= maxReverseScrolls) { - tester.printToConsole('Maximum reverse scrolls reached. Widget not found.'); - break; - } - - // Perform scrolling in the current direction - await tester.drag( - scrollableFinder, - scrollDown ? Offset(0, -scrollStep) : Offset(0, scrollStep), - ); - await tester.pumpAndSettle(); - - // Update the scroll position after the drag - final currentScrollPosition = scrollableState.position.pixels; - - if (currentScrollPosition == previousScrollPosition) { - // Cannot scroll further in the current direction - if (reversedDirection) { - // We've already tried both directions - tester.printToConsole('Reached the scroll limit in both directions. Widget not found.'); - break; - } else { - // Reverse the scroll direction and reset reverse scroll count - scrollDown = !scrollDown; - reversedDirection = true; - reverseScrollCount = 0; - tester.printToConsole('Reached the end, reversing direction'); - } - } else { - // Update scroll position and reverse scroll count, incrementing only for reverse scrolling - previousScrollPosition = currentScrollPosition; - if (!scrollDown) reverseScrollCount++; - } - } - - if (!found) { - tester.printToConsole('Widget not found after $maxScrolls scrolls.'); - } - } - - Future scrollItemIntoView( - String itemKeyId, - double scrollPixels, - String scrollableFinderKey, - ) async { - final Finder itemFinder = find.byKey(ValueKey(itemKeyId)); - - final scrollableFinder = find.descendant( - of: find.byKey(ValueKey(scrollableFinderKey)), - matching: find.byType(Scrollable), - ); - - // Check if the item is already visible - if (tester.any(itemFinder)) { - tester.printToConsole('Item $itemKeyId is already visible'); - return; - } - - // Check if scrollable exists - if (!tester.any(scrollableFinder)) { - tester.printToConsole('Scrollable not found for $itemKeyId'); - return; - } - - try { - await tester.scrollUntilVisible( - itemFinder, - scrollPixels, - scrollable: scrollableFinder, - maxScrolls: 10, - ); - - // Wait for the scroll to complete - await tester.pumpAndSettle(Duration(milliseconds: 500)); - - // Verify the item is now visible - if (!tester.any(itemFinder)) { - tester.printToConsole('Item $itemKeyId not found after scrolling'); - } - } catch (e) { - tester.printToConsole('Could not scroll to $itemKeyId: $e'); - } - } - - Future enterText(String text, String editableTextKey) async { - final editableTextWidget = find.byKey(ValueKey((editableTextKey))); - - await tester.enterText(editableTextWidget, text); - - await tester.pumpAndSettle(); - } - - void findWidgetViaDescendant({ - required FinderBase of, - required FinderBase matching, - }) { - final textWidget = find.descendant(of: of, matching: matching); - - expect(textWidget, findsOneWidget); - } - - Future defaultSleepTime({int seconds = 2}) async => - await Future.delayed(Duration(seconds: seconds)); - - Future takeScreenshots(String screenshotName) async { - // Pausing this for now - // await (tester.binding as IntegrationTestWidgetsFlutterBinding).takeScreenshot(screenshotName); - } -} diff --git a/integration_test/components/common_test_constants.dart b/integration_test/components/common_test_constants.dart deleted file mode 100644 index f26254401..000000000 --- a/integration_test/components/common_test_constants.dart +++ /dev/null @@ -1,15 +0,0 @@ -import 'package:cw_core/crypto_currency.dart'; -import 'package:cw_core/wallet_type.dart'; - -class CommonTestConstants { - static final pin = [0, 8, 0, 1]; - static final String sendTestAmount = '0.00008'; - static final String sendTestFiatAmount = '1.00'; - static final String exchangeTestAmount = '0.01'; - static final WalletType testWalletType = WalletType.solana; - static final String testWalletName = 'Integrated Testing Wallet'; - static final CryptoCurrency sendTestReceiveCurrency = CryptoCurrency.sol; - static final CryptoCurrency exchangeTestReceiveCurrency = CryptoCurrency.usdtSol; - static final CryptoCurrency exchangeTestDepositCurrency = CryptoCurrency.sol; - static final String testWalletAddress = '7TSTXnQZxQDA4JoNqs4DqVqE7oNWk2kmHkMT6DD6vw2S'; -} diff --git a/integration_test/components/common_test_flows.dart b/integration_test/components/common_test_flows.dart deleted file mode 100644 index 888c2ce03..000000000 --- a/integration_test/components/common_test_flows.dart +++ /dev/null @@ -1,414 +0,0 @@ -import 'dart:io'; - -import 'package:cake_wallet/entities/seed_type.dart'; -import 'package:cake_wallet/reactions/wallet_utils.dart'; -import 'package:cake_wallet/wallet_types.g.dart'; -import 'package:cw_core/currency_for_wallet_type.dart'; -import 'package:cw_core/wallet_type.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; - -import 'package:cake_wallet/main.dart' as app; - -import '../robots/create_pin_welcome_page_robot.dart'; -import '../robots/new_wallet_page_robot.dart'; -import '../robots/new_wallet_type_page_robot.dart'; -import '../robots/pre_seed_page_robot.dart'; -import '../robots/restore_from_seed_or_key_robot.dart'; -import '../robots/restore_options_page_robot.dart'; -import '../robots/seed_verification_page_robot.dart'; -import '../robots/setup_pin_code_robot.dart'; -import '../robots/wallet_group_description_page_robot.dart'; -import '../robots/wallet_list_page_robot.dart'; -import '../robots/wallet_seed_page_robot.dart'; -import '../robots/welcome_page_robot.dart'; -import 'common_test_cases.dart'; -import 'package:cake_wallet/.secrets.g.dart' as secrets; - -import 'common_test_constants.dart'; - -class CommonTestFlows { - CommonTestFlows(this._tester) - : _commonTestCases = CommonTestCases(_tester), - _welcomePageRobot = WelcomePageRobot(_tester), - _preSeedPageRobot = PreSeedPageRobot(_tester), - _setupPinCodeRobot = SetupPinCodeRobot(_tester), - _newWalletPageRobot = NewWalletPageRobot(_tester), - _walletSeedPageRobot = WalletSeedPageRobot(_tester), - _walletListPageRobot = WalletListPageRobot(_tester), - _newWalletTypePageRobot = NewWalletTypePageRobot(_tester), - _restoreOptionsPageRobot = RestoreOptionsPageRobot(_tester), - _seedVerificationPageRobot = SeedVerificationPageRobot(_tester), - _createPinWelcomePageRobot = CreatePinWelcomePageRobot(_tester), - _restoreFromSeedOrKeysPageRobot = RestoreFromSeedOrKeysPageRobot(_tester), - _walletGroupDescriptionPageRobot = WalletGroupDescriptionPageRobot(_tester); - - final WidgetTester _tester; - final CommonTestCases _commonTestCases; - - final WelcomePageRobot _welcomePageRobot; - final PreSeedPageRobot _preSeedPageRobot; - final SetupPinCodeRobot _setupPinCodeRobot; - final NewWalletPageRobot _newWalletPageRobot; - final WalletSeedPageRobot _walletSeedPageRobot; - final WalletListPageRobot _walletListPageRobot; - final NewWalletTypePageRobot _newWalletTypePageRobot; - final RestoreOptionsPageRobot _restoreOptionsPageRobot; - final CreatePinWelcomePageRobot _createPinWelcomePageRobot; - final SeedVerificationPageRobot _seedVerificationPageRobot; - final RestoreFromSeedOrKeysPageRobot _restoreFromSeedOrKeysPageRobot; - final WalletGroupDescriptionPageRobot _walletGroupDescriptionPageRobot; - - //* ========== Handles flow to start the app afresh and accept disclaimer ============= - Future startAppFlow(Key key) async { - await app.main(topLevelKey: key); - - await _tester.pump(Duration(seconds: 2)); - - // // --------- Disclaimer Page ------------ - // // Tap checkbox to accept disclaimer - // await _disclaimerPageRobot.tapDisclaimerCheckbox(); - - // // Tap accept button - // await _disclaimerPageRobot.tapAcceptButton(); - } - - //* ========== Handles flow from welcome to creating a new wallet =============== - Future welcomePageToCreateNewWalletFlow( - WalletType walletTypeToCreate, - List walletPin, - ) async { - await _welcomeToCreateWalletPath(walletTypeToCreate, walletPin); - - await _generateNewWalletDetails(); - - await _confirmPreSeedInfo(); - - await _confirmWalletDetails(); - - await _verifyWalletSeed(); - } - - //* ========== Handles flow from welcome to restoring wallet from seeds =============== - Future welcomePageToRestoreWalletThroughSeedsFlow( - WalletType walletTypeToRestore, - String walletSeed, - List walletPin, - ) async { - await _welcomeToRestoreFromSeedsOrKeysPath(walletTypeToRestore, walletPin); - await _restoreFromSeeds(walletTypeToRestore, walletSeed); - } - - //* ========== Handles flow from welcome to restoring wallet from keys =============== - Future welcomePageToRestoreWalletThroughKeysFlow( - WalletType walletTypeToRestore, - List walletPin, - ) async { - await _welcomeToRestoreFromSeedsOrKeysPath(walletTypeToRestore, walletPin); - await _restoreFromKeys(); - } - - void confirmAllAvailableWalletTypeIconsDisplayCorrectly() { - for (var walletType in availableWalletTypes) { - final imageUrl = walletTypeToCryptoCurrency(walletType).iconPath; - - final walletIconFinder = find.image( - Image.asset( - imageUrl!, - width: 32, - height: 32, - ).image, - ); - - expect(walletIconFinder, findsAny); - } - } - - //* ========== Handles creating new wallet flow from wallet list/menu =============== - Future createNewWalletFromWalletMenu(WalletType walletTypeToCreate) async { - _tester.printToConsole('Creating ${walletTypeToCreate.name} Wallet'); - - await _tester.pumpAndSettle(Duration(milliseconds: 1000)); - - await _walletListPageRobot.navigateToCreateNewWalletPage(); - await _commonTestCases.defaultSleepTime(); - - await _selectWalletTypeForWallet(walletTypeToCreate); - await _commonTestCases.defaultSleepTime(); - - // ---- Wallet Group/New Seed Implementation Comes here - await _walletGroupDescriptionPageFlow(true, walletTypeToCreate); - - await _generateNewWalletDetails(); - - await _confirmPreSeedInfo(); - - await _confirmWalletDetails(); - - await _verifyWalletSeed(); - - await _commonTestCases.defaultSleepTime(); - } - - Future _walletGroupDescriptionPageFlow(bool isNewSeed, WalletType walletType) async { - if (!isBIP39Wallet(walletType)) return; - - await _walletGroupDescriptionPageRobot.isWalletGroupDescriptionPage(); - - if (isNewSeed) { - await _walletGroupDescriptionPageRobot.navigateToCreateNewSeedPage(); - } else { - await _walletGroupDescriptionPageRobot.navigateToChooseWalletGroup(); - } - } - - //* ========== Handles restore wallet flow from wallet list/menu =============== - Future restoreWalletFromWalletMenu(WalletType walletType, String walletSeed) async { - _tester.printToConsole('Restoring ${walletType.name} Wallet'); - await _walletListPageRobot.navigateToRestoreWalletOptionsPage(); - await _commonTestCases.defaultSleepTime(); - - if (!Platform.isLinux) await _restoreOptionsPageRobot.navigateToRestoreFromSeedsOrKeysPage(); - if (!Platform.isLinux) await _commonTestCases.defaultSleepTime(); - - await _selectWalletTypeForWallet(walletType); - await _commonTestCases.defaultSleepTime(); - - await _restoreFromSeeds(walletType, walletSeed); - await _commonTestCases.defaultSleepTime(); - } - - //* ========== Handles setting up pin code for wallet on first install =============== - Future setupPinCodeForWallet(List pin) async { - if (Platform.isLinux) return; - // ----------- SetupPinCode Page ------------- - // Confirm initial defaults - Widgets to be displayed etc - await _setupPinCodeRobot.isSetupPinCodePage(); - - await _setupPinCodeRobot.enterPinCode(pin); - await _setupPinCodeRobot.enterPinCode(pin); - await _setupPinCodeRobot.tapSuccessButton(); - } - - Future _welcomeToCreateWalletPath( - WalletType walletTypeToCreate, - List pin, - ) async { - await _createPinWelcomePageRobot.tapSetAPinButton(); - - await setupPinCodeForWallet(pin); - - await _welcomePageRobot.navigateToCreateNewWalletPage(); - - await _selectWalletTypeForWallet(walletTypeToCreate); - - if (_welcomePageRobot.hasNewSingleSeedButton()) { - await _welcomePageRobot.tapNewSingleSeed(); - } - } - - Future _welcomeToRestoreFromSeedsOrKeysPath( - WalletType walletTypeToRestore, - List pin, - ) async { - await _createPinWelcomePageRobot.tapSetAPinButton(); - - await setupPinCodeForWallet(pin); - - await _welcomePageRobot.navigateToRestoreWalletPage(); - - if (!Platform.isLinux) await _restoreOptionsPageRobot.navigateToRestoreFromSeedsOrKeysPage(); - - await _selectWalletTypeForWallet(walletTypeToRestore); - } - - //* ============ Handles New Wallet Type Page ================== - Future _selectWalletTypeForWallet(WalletType type) async { - // ----------- NewWalletType Page ------------- - _tester.printToConsole('Selecting wallet type: ${type.name}'); - - await _tester.pumpAndSettle(Duration(milliseconds: 1000)); - - await _newWalletTypePageRobot.findParticularWalletTypeInScrollableList(type); - - // Tapping the wallet type navigates directly (no separate Next button). - _tester.printToConsole('Tapping wallet type: ${type.name}'); - await _newWalletTypePageRobot.selectWalletType(type); - - await _tester.pumpAndSettle(Duration(milliseconds: 2000)); - - _tester.printToConsole('Wallet type selection completed for: ${type.name}'); - } - - //* ============ Handles New Wallet Page ================== - Future _generateNewWalletDetails() async { - _tester.printToConsole('Attempting to find NewWalletPage...'); - - await _tester.pumpAndSettle(Duration(milliseconds: 1000)); - - await _newWalletPageRobot.isNewWalletPage(); - _tester.printToConsole('NewWalletPage found successfully'); - - await _newWalletPageRobot.generateWalletName(); - - if (Platform.isLinux) { - // manual pin input - await _restoreFromSeedOrKeysPageRobot - .enterPasswordForWalletRestore(CommonTestConstants.pin.join("")); - await _restoreFromSeedOrKeysPageRobot - .enterPasswordRepeatForWalletRestore(CommonTestConstants.pin.join("")); - } - - await _newWalletPageRobot.onNextButtonPressed(); - } - - //* ============ Handles Pre Seed Page ===================== - Future _confirmPreSeedInfo() async { - await _preSeedPageRobot.isPreSeedPage(); - - await _preSeedPageRobot.onConfirmButtonPressed(); - } - - //* ============ Handles Wallet Seed Page ================== - Future _confirmWalletDetails() async { - await _walletSeedPageRobot.isWalletSeedPage(); - - _walletSeedPageRobot.confirmWalletDetailsDisplayCorrectly(); - - _walletSeedPageRobot.confirmWalletSeedReminderDisplays(); - - // await _walletSeedPageRobot.onCopySeedsButtonPressed(); - - await _walletSeedPageRobot.onVerifySeedButtonPressed(); - } - - //* ============ Handles Wallet Seed Verification Page ================== - - Future _verifyWalletSeed() async { - await _seedVerificationPageRobot.isSeedVerificationPage(); - - _seedVerificationPageRobot.hasTitle(); - - await _seedVerificationPageRobot.verifyWalletSeeds(); - } - - //* Main Restore Actions - On the RestoreFromSeed/Keys Page - Restore from Seeds Action - Future _restoreFromSeeds(WalletType type, String walletSeed) async { - // ----------- RestoreFromSeedOrKeys Page ------------- - - await _restoreFromSeedOrKeysPageRobot.selectWalletNameFromAvailableOptions(); - await _restoreFromSeedOrKeysPageRobot.enterSeedPhraseForWalletRestore(walletSeed); - - final numberOfWords = walletSeed.split(' ').length; - - if (numberOfWords == 25 && (type == WalletType.monero)) { - await _restoreFromSeedOrKeysPageRobot - .chooseSeedTypeForMoneroOrWowneroWallets(MoneroSeedType.legacy); - - // Using a constant value of 2831400 for the blockheight as its the restore blockheight for our testing wallet - await _restoreFromSeedOrKeysPageRobot - .enterBlockHeightForWalletRestore(secrets.moneroTestWalletBlockHeight); - } - - if (Platform.isLinux) { - // manual pin input - await _restoreFromSeedOrKeysPageRobot - .enterPasswordForWalletRestore(CommonTestConstants.pin.join("")); - await _restoreFromSeedOrKeysPageRobot - .enterPasswordRepeatForWalletRestore(CommonTestConstants.pin.join("")); - } - - await _restoreFromSeedOrKeysPageRobot.onRestoreWalletButtonPressed(); - } - - //* Main Restore Actions - On the RestoreFromSeed/Keys Page - Restore from Keys Action - Future _restoreFromKeys() async { - await _commonTestCases.swipePage(); - await _commonTestCases.defaultSleepTime(); - - await _restoreFromSeedOrKeysPageRobot.selectWalletNameFromAvailableOptions( - isSeedFormEntry: false, - ); - - await _restoreFromSeedOrKeysPageRobot.enterSeedPhraseForWalletRestore(''); - await _restoreFromSeedOrKeysPageRobot.onRestoreWalletButtonPressed(); - } - - //* ====== Utility Function to get test wallet seeds for each wallet type ======== - String getWalletSeedsByWalletType(WalletType walletType) { - switch (walletType) { - case WalletType.monero: - return secrets.moneroTestWalletSeeds; - case WalletType.bitcoin: - return secrets.bitcoinTestWalletSeeds; - case WalletType.ethereum: - return secrets.ethereumTestWalletSeeds; - case WalletType.litecoin: - return secrets.litecoinTestWalletSeeds; - case WalletType.bitcoinCash: - return secrets.bitcoinCashTestWalletSeeds; - case WalletType.polygon: - return secrets.polygonTestWalletSeeds; - case WalletType.solana: - return secrets.solanaTestWalletSeeds; - case WalletType.base: - return secrets.baseTestWalletSeeds; - case WalletType.arbitrum: - return secrets.arbitrumTestWalletSeeds; - case WalletType.bsc: - return secrets.bscTestWalletSeeds; - case WalletType.tron: - return secrets.tronTestWalletSeeds; - case WalletType.nano: - return secrets.nanoTestWalletSeeds; - case WalletType.wownero: - return secrets.wowneroTestWalletSeeds; - case WalletType.zano: - return secrets.zanoTestWalletSeeds; - case WalletType.decred: - return secrets.decredTestWalletSeeds; - case WalletType.dogecoin: - return secrets.dogeTestWalletSeeds; - case WalletType.zcash: - return secrets.zcashTestWalletSeeds; - case WalletType.none: - case WalletType.haven: - case WalletType.banano: - throw Exception("Unable to get seeds for ${walletType}"); - } - } - - //* ====== Utility Function to get test receive address for each wallet type ======== - String getReceiveAddressByWalletType(WalletType walletType) { - switch (walletType) { - case WalletType.monero: - return secrets.moneroTestWalletReceiveAddress; - case WalletType.bitcoin: - return secrets.bitcoinTestWalletReceiveAddress; - case WalletType.ethereum: - return secrets.ethereumTestWalletReceiveAddress; - case WalletType.litecoin: - return secrets.litecoinTestWalletReceiveAddress; - case WalletType.bitcoinCash: - return secrets.bitcoinCashTestWalletReceiveAddress; - case WalletType.polygon: - return secrets.polygonTestWalletReceiveAddress; - case WalletType.base: - return secrets.baseTestWalletReceiveAddress; - case WalletType.arbitrum: - return secrets.arbitrumTestWalletReceiveAddress; - case WalletType.bsc: - return secrets.bscTestWalletReceiveAddress; - case WalletType.solana: - return secrets.solanaTestWalletReceiveAddress; - case WalletType.tron: - return secrets.tronTestWalletReceiveAddress; - case WalletType.nano: - return secrets.nanoTestWalletReceiveAddress; - case WalletType.wownero: - return secrets.wowneroTestWalletReceiveAddress; - default: - return ''; - } - } -} diff --git a/integration_test/core/app_launcher.dart b/integration_test/core/app_launcher.dart new file mode 100644 index 000000000..6f12bec19 --- /dev/null +++ b/integration_test/core/app_launcher.dart @@ -0,0 +1,75 @@ +import "dart:async"; + +import "package:cake_wallet/main.dart" as app; +import "package:flutter/foundation.dart"; +import "package:flutter_test/flutter_test.dart"; + +import "benign_errors.dart"; + +void integrationTest(String description, Future Function(WidgetTester tester) body) { + testWidgets(description, (tester) async { + final completer = Completer(); + + final run = runZonedGuarded(() async { + try { + await body(tester); + + if (!completer.isCompleted) { + completer.complete(); + } + } catch (error, stack) { + if (!completer.isCompleted) { + completer.completeError(error, stack); + } + } + }, (error, stack) { + final kind = benignErrorKind(error.toString()); + + if (kind != null) { + debugPrint("Ignoring benign $kind background error: $error"); + return; + } + + if (completer.isCompleted) { + debugPrint("Background error after the test finished: $error"); + return; + } + + completer.completeError(error, stack); + }); + + unawaited(run); + + await completer.future; + }); +} + +class AppLauncher { + AppLauncher(this.tester); + + final WidgetTester tester; + + Future launchApp({required String testKey}) async { + final bindingHandler = FlutterError.onError; + + await app.main(topLevelKey: ValueKey(testKey)); + + _installTestErrorHandler(bindingHandler); + + await tester.pump(const Duration(seconds: 2)); + } + + void _installTestErrorHandler(FlutterExceptionHandler? bindingHandler) { + FlutterError.onError = (details) { + final reportedByTestFramework = details.library == "Flutter test framework"; + final kind = reportedByTestFramework ? null : benignErrorKind(details.exceptionAsString()); + + if (kind != null) { + debugPrint("Ignoring benign $kind error: ${details.exceptionAsString()}"); + return; + } + + bindingHandler?.call(details); + }; + } +} diff --git a/integration_test/core/base_robot.dart b/integration_test/core/base_robot.dart new file mode 100644 index 000000000..7ee2113d2 --- /dev/null +++ b/integration_test/core/base_robot.dart @@ -0,0 +1,238 @@ +import "dart:async"; + +import "package:flutter/material.dart"; +import "package:flutter_test/flutter_test.dart"; +import "package:integration_test/integration_test.dart"; + +import "benign_errors.dart"; + +abstract class BaseRobot { + BaseRobot(this.tester); + + final WidgetTester tester; + + Future isDisplayed(); + + void drainBackgroundErrors() { + final Object? pending = tester.takeException(); + + if (pending == null) { + return; + } + + final kind = benignErrorKind(pending.toString()); + + if (kind != null) { + tester.printToConsole("Ignoring benign $kind background error: $pending"); + return; + } + + throw TestFailure("Unhandled background error: $pending"); + } + + Future _pumpStep(Duration step) async { + await tester.pump(step); + + drainBackgroundErrors(); + } + + Future pumpUntilFound( + Finder finder, { + Duration timeout = const Duration(seconds: 30), + Duration step = const Duration(milliseconds: 100), + }) async { + final endTime = DateTime.now().add(timeout); + + while (DateTime.now().isBefore(endTime)) { + await _pumpStep(step); + + if (tester.any(finder)) { + return; + } + } + + await takeScreenshot("${runtimeType}_pump_until_found_timeout"); + throw TestFailure("Widget not found after ${timeout.inSeconds}s: $finder"); + } + + Future pumpUntilGone( + Finder finder, { + Duration timeout = const Duration(seconds: 30), + Duration step = const Duration(milliseconds: 100), + }) async { + final endTime = DateTime.now().add(timeout); + + while (DateTime.now().isBefore(endTime)) { + await _pumpStep(step); + + if (!tester.any(finder)) { + return; + } + } + + await takeScreenshot("${runtimeType}_pump_until_gone_timeout"); + throw TestFailure("Widget still present after ${timeout.inSeconds}s: $finder"); + } + + Future tapByKey(String key, {Duration timeout = const Duration(seconds: 30)}) async { + final finder = find.byKey(ValueKey(key)); + + await pumpUntilFound(finder, timeout: timeout); + + // A tap during a route transition lands on the animating overlay instead of the widget + await settle(max: const Duration(seconds: 2)); + + await tester.tap(finder.first, warnIfMissed: false); + await tester.pump(const Duration(milliseconds: 300)); + } + + Future tapWhenVisible( + Finder finder, { + Duration timeout = const Duration(seconds: 30), + }) async { + await pumpUntilFound(finder, timeout: timeout); + + await settle(max: const Duration(seconds: 2)); + + await tester.tap(finder.first, warnIfMissed: false); + await tester.pump(const Duration(milliseconds: 300)); + } + + Future enterTextByKey(String key, String text) async { + final finder = find.byKey(ValueKey(key)); + + await pumpUntilFound(finder); + + await tester.enterText(finder.first, text); + await tester.pump(const Duration(milliseconds: 300)); + } + + Future pumpUntil( + bool Function() condition, { + Duration timeout = const Duration(seconds: 30), + Duration step = const Duration(milliseconds: 100), + }) async { + final endTime = DateTime.now().add(timeout); + + while (DateTime.now().isBefore(endTime)) { + await _pumpStep(step); + + if (condition()) { + return true; + } + } + + return false; + } + + Future goBack() async { + final navigator = tester.state(find.byType(Navigator).last); + navigator.pop(); + + await tester.pump(const Duration(milliseconds: 500)); + } + + Future dismissModal() async { + final navigator = tester.state(find.byType(Navigator).first); + navigator.pop(); + + await tester.pump(const Duration(milliseconds: 500)); + } + + // We make this bounded instead of pumpAndSettle + Future settle({Duration max = const Duration(seconds: 3)}) async { + final endTime = DateTime.now().add(max); + + while (DateTime.now().isBefore(endTime)) { + await _pumpStep(const Duration(milliseconds: 100)); + + if (!tester.binding.hasScheduledFrame) { + return; + } + } + } + + Future isSpecificPage({Duration timeout = const Duration(seconds: 30)}) async { + await pumpUntilFound(find.byType(T), timeout: timeout); + + await settle(); + + hasType(); + } + + bool isKeyPresent(String key) => tester.any(find.byKey(ValueKey(key))); + + String describeScreen() { + final names = tester.allWidgets + .map((widget) => widget.runtimeType.toString()) + .where((name) => name.endsWith("Page") || name.endsWith("Sheet") || name.endsWith("Screen")) + .toSet() + .toList() + ..sort(); + + return names.join(", "); + } + + void hasType() { + expect(find.byType(T), findsOneWidget); + } + + void hasText(String text) { + expect(find.text(text), findsOneWidget); + } + + void hasTextAtLeastOnce(String text) { + expect(find.text(text), findsAny); + } + + String? textByKey(String key) { + final finder = find.byKey(ValueKey(key)); + + if (!tester.any(finder)) { + return null; + } + + return tester.widget(finder.first).data; + } + + static bool _surfaceConverted = false; + + Future takeScreenshot(String name) async { + try { + final binding = tester.binding; + + if (binding is! IntegrationTestWidgetsFlutterBinding) { + return; + } + + if (!_surfaceConverted) { + await binding.convertFlutterSurfaceToImage(); + _surfaceConverted = true; + } + + var finished = false; + + unawaited( + binding.takeScreenshot("${name}_${DateTime.now().millisecondsSinceEpoch}").then( + (_) => finished = true, + onError: (Object error) { + finished = true; + tester.printToConsole("Screenshot $name failed: $error"); + }, + ), + ); + + final deadline = DateTime.now().add(const Duration(seconds: 10)); + + while (!finished && DateTime.now().isBefore(deadline)) { + await tester.pump(const Duration(milliseconds: 100)); + } + + if (!finished) { + tester.printToConsole("Screenshot $name gave up after 10s"); + } + } catch (e) { + tester.printToConsole("Screenshot $name failed: $e"); + } + } +} diff --git a/integration_test/core/benign_errors.dart b/integration_test/core/benign_errors.dart new file mode 100644 index 000000000..3e2248438 --- /dev/null +++ b/integration_test/core/benign_errors.dart @@ -0,0 +1,51 @@ +String? benignErrorKind(String message) { + if (_toleratedAssertions.any(message.contains)) { + return "assertion"; + } + + if (_toleratedAssets.any(message.contains)) { + return "asset"; + } + + if (_toleratedNetworkNoise.any(message.contains)) { + return "network"; + } + + return null; +} + +// Debug build assertions. The semantics ones are real accessibility bugs that already +// exist on dev, take them off this list once they are fixed. +const List _toleratedAssertions = [ + "overflowed by", + "minValue, and maxValue must be valid numbers", + "node.parent?._dirty", + "RenderBox was not laid out", +]; + +// CakeImageWidget reports these before falling back to the svg +const List _toleratedAssets = [ + "Unable to load asset", + "NetworkImageLoadException", + "Failed to load network image", +]; + +// The same failures exception_handler.dart ignores +const List _toleratedNetworkNoise = [ + "SocketException", + "HttpException", + "ClientException", + "HandshakeException", + "TimeoutException", + "Failed host lookup", + "Connection closed", + "Connection reset by peer", + "Connection refused", + "Connection timed out", + "Operation timed out", + "Network is unreachable", + "No route to host", + "Broken pipe", + "Software caused connection abort", + "Connection closed before full header", +]; diff --git a/integration_test/core/funded_wallet.dart b/integration_test/core/funded_wallet.dart new file mode 100644 index 000000000..fdaf6871e --- /dev/null +++ b/integration_test/core/funded_wallet.dart @@ -0,0 +1,6 @@ +class FundedWallet { + const FundedWallet({required this.seed, this.passphrase = ""}); + + final String seed; + final String passphrase; +} diff --git a/integration_test/core/funded_wallets.dart b/integration_test/core/funded_wallets.dart new file mode 100644 index 000000000..6076fcec6 --- /dev/null +++ b/integration_test/core/funded_wallets.dart @@ -0,0 +1,5 @@ +import "funded_wallet.dart"; + +// Empty on purpose and it has to stay empty in git. The manual funds workflow overwrites +// this file from the FUNDS_SECRETS_FILE secret, keyed by WalletType name. +const Map> fundedWallets = {}; diff --git a/integration_test/core/funds_outcome.dart b/integration_test/core/funds_outcome.dart new file mode 100644 index 000000000..b8900fcd2 --- /dev/null +++ b/integration_test/core/funds_outcome.dart @@ -0,0 +1,46 @@ +import "package:flutter_test/flutter_test.dart"; + +class FundsOutcome { + static const marker = "FUNDS_RESULT"; + + static void ok(WidgetTester tester, String chain, String detail) => + _write(tester, chain, "ok", detail); + + static void empty(WidgetTester tester, String chain, String detail) => + _write(tester, chain, "empty", detail); + + static void waiting(WidgetTester tester, String chain, String detail) => + _write(tester, chain, "waiting", detail); + + static void failed(WidgetTester tester, String chain, String detail) => + _write(tester, chain, statusFor(detail), detail); + + static String statusFor(String detail) { + if (_isWalletBalanceLow(detail)) { + return "empty"; + } + + return _isChainTooSlowToSync(detail) ? "waiting" : "failed"; + } + + static bool needsFurtherReview(String detail) => statusFor(detail) == "failed"; + + static bool _isChainTooSlowToSync(String detail) => + detail.toLowerCase().contains("never became ready to send"); + + // Low wallet balance is not a failure, we just need to add fundsss + static bool _isWalletBalanceLow(String detail) { + final wording = detail.toLowerCase(); + + return wording.contains("not enough") || + wording.contains("wrong balance") || + wording.contains("insufficient") || + wording.contains("no spendable"); + } + + static void _write(WidgetTester tester, String chain, String status, String detail) { + final clean = detail.replaceAll("\n", " ").replaceAll("|", "/").trim(); + + tester.printToConsole("$marker|$chain|$status|$clean"); + } +} diff --git a/integration_test/core/test_config.dart b/integration_test/core/test_config.dart new file mode 100644 index 000000000..fb6404b7f --- /dev/null +++ b/integration_test/core/test_config.dart @@ -0,0 +1,112 @@ +import "package:cake_wallet/wallet_types.g.dart"; +import "package:cw_core/wallet_type.dart"; + +import "test_wallets.dart"; + +class TestConfig { + static final List pin = [0, 8, 0, 1]; + + static const bool isCIBuild = bool.fromEnvironment("CI_BUILD"); + + static const String _walletTypesOverride = String.fromEnvironment("TEST_WALLET_TYPES"); + + // Solana and ethereum cover the cheap key-derivation chains, + // bitcoin covers the electrum family and monero covers the native-library chains. + static final List _representativeWalletTypes = [ + WalletType.solana, + WalletType.ethereum, + WalletType.bitcoin, + WalletType.monero, + ]; + + static List get walletTypesUnderTest { + if (_walletTypesOverride == "all") { + return availableWalletTypes; + } + + if (_walletTypesOverride.isNotEmpty) { + return _walletTypesOverride + .split(",") + .map((name) => WalletType.values.byName(name.trim())) + .where(availableWalletTypes.contains) + .toList(); + } + + return _representativeWalletTypes.where(availableWalletTypes.contains).toList(); + } + + static const String _fundsFlows = String.fromEnvironment("FLOWS", defaultValue: "all"); + + static const String _spend = String.fromEnvironment("SPEND", defaultValue: "false"); + + static const String _swapFrom = String.fromEnvironment("SWAP_FROM"); + + static const String swapReceive = String.fromEnvironment("SWAP_RECEIVE"); + + static WalletType swapDepositType(List funded) => + _swapFrom.isEmpty ? funded.first : _walletTypeByName(_swapFrom, "SWAP_FROM"); + + static bool shouldRunFundsFlow(String flow) => _fundsFlows == "all" || _fundsFlows == flow; + + static bool shouldDryRun(String flow) => shouldRunFundsFlow(flow); + + static bool shouldSpend(String flow) => _spend == "true" && shouldRunFundsFlow(flow); + + static const String _fundedChainsOverride = String.fromEnvironment( + "CHAINS", + defaultValue: "auto", + ); + + static List get fundedWalletTypesUnderTest { + if (_fundedChainsOverride == "auto") { + return TestWallets.fundedWalletTypes; + } + + return _fundedChainsOverride + .split(",") + .map((name) => _walletTypeByName(name, "CHAINS")) + .where((type) => TestWallets.fundedWalletsFor(type).isNotEmpty) + .toList(); + } + + static const Map _fundsSendAmounts = { + "solana": "0.0001", + "ethereum": "0.00001", + "polygon": "0.01", + "base": "0.00001", + "arbitrum": "0.00001", + "bsc": "0.000001", + "tron": "1", + "bitcoin": "0.00001", + "litecoin": "0.001", + "bitcoinCash": "0.0002", + "dogecoin": "2", + "monero": "0.0001", + "wownero": "0.1", + "zcash": "0.0001", + "nano": "0.000001", + "decred": "0.001", + "zano": "0.001", + }; + + static String fundsSendAmountFor(WalletType type) => _fundsSendAmounts[type.name] ?? "0.0001"; + + // Making this two hours to accommodate monero that takes a longgg time syncing + // also, this would only affect the funded tests which need actual funds to test + static const Duration walletSyncBudget = Duration(hours: 2); + + static WalletType _walletTypeByName(String name, String input) { + final trimmed = name.trim(); + + final type = WalletType.values.where((value) => value.name == trimmed).toList(); + + if (type.isEmpty) { + throw ArgumentError( + 'Unknown wallet type "$trimmed" in $input, ' + "valid names: ${WalletType.values.map((value) => value.name).join(", ")}", + ); + } + + return type.first; + } +} diff --git a/integration_test/core/test_wallets.dart b/integration_test/core/test_wallets.dart new file mode 100644 index 000000000..68565c763 --- /dev/null +++ b/integration_test/core/test_wallets.dart @@ -0,0 +1,102 @@ +import "package:cake_wallet/.secrets.g.dart" as secrets; +import "package:cake_wallet/wallet_types.g.dart"; +import "package:cw_core/wallet_type.dart"; + +import "funded_wallet.dart"; +import "funded_wallets.dart"; + +class TestWallets { + static List get fundedWalletTypes => + availableWalletTypes.where((type) => fundedWalletsFor(type).isNotEmpty).toList(); + + static List fundedWalletsFor(WalletType type) => + (fundedWallets[type.name] ?? []).where((wallet) => wallet.seed.trim().isNotEmpty).toList(); + + static String seedFor(WalletType type) { + switch (type) { + case WalletType.monero: + return secrets.moneroTestWalletSeeds; + case WalletType.bitcoin: + return secrets.bitcoinTestWalletSeeds; + case WalletType.ethereum: + return secrets.ethereumTestWalletSeeds; + case WalletType.litecoin: + return secrets.litecoinTestWalletSeeds; + case WalletType.bitcoinCash: + return secrets.bitcoinCashTestWalletSeeds; + case WalletType.polygon: + return secrets.polygonTestWalletSeeds; + case WalletType.solana: + return secrets.solanaTestWalletSeeds; + case WalletType.base: + return secrets.baseTestWalletSeeds; + case WalletType.arbitrum: + return secrets.arbitrumTestWalletSeeds; + case WalletType.bsc: + return secrets.bscTestWalletSeeds; + case WalletType.tron: + return secrets.tronTestWalletSeeds; + case WalletType.nano: + return secrets.nanoTestWalletSeeds; + case WalletType.wownero: + return secrets.wowneroTestWalletSeeds; + case WalletType.zano: + return secrets.zanoTestWalletSeeds; + case WalletType.decred: + return secrets.decredTestWalletSeeds; + case WalletType.dogecoin: + return secrets.dogeTestWalletSeeds; + case WalletType.zcash: + return secrets.zcashTestWalletSeeds; + case WalletType.none: + case WalletType.haven: + case WalletType.banano: + throw Exception("No test wallet seed available for ${type.name}"); + } + } + + static String receiveAddressFor(WalletType type) { + switch (type) { + case WalletType.monero: + return secrets.moneroTestWalletReceiveAddress; + case WalletType.bitcoin: + return secrets.bitcoinTestWalletReceiveAddress; + case WalletType.ethereum: + return secrets.ethereumTestWalletReceiveAddress; + case WalletType.litecoin: + return secrets.litecoinTestWalletReceiveAddress; + case WalletType.bitcoinCash: + return secrets.bitcoinCashTestWalletReceiveAddress; + case WalletType.polygon: + return secrets.polygonTestWalletReceiveAddress; + case WalletType.base: + return secrets.baseTestWalletReceiveAddress; + case WalletType.arbitrum: + return secrets.arbitrumTestWalletReceiveAddress; + case WalletType.bsc: + return secrets.bscTestWalletReceiveAddress; + case WalletType.solana: + return secrets.solanaTestWalletReceiveAddress; + case WalletType.tron: + return secrets.tronTestWalletReceiveAddress; + case WalletType.nano: + return secrets.nanoTestWalletReceiveAddress; + case WalletType.wownero: + return secrets.wowneroTestWalletReceiveAddress; + case WalletType.zano: + case WalletType.decred: + case WalletType.dogecoin: + case WalletType.zcash: + case WalletType.none: + case WalletType.haven: + case WalletType.banano: + return ""; + } + } + + static String get moneroLegacySeed => secrets.moneroLegacyTestWalletSeeds; + + static String get moneroRestoreBlockHeight => secrets.moneroTestWalletBlockHeight; + + static String get zcashRestoreHeight => secrets.zcashTestWalletRestoreHeight; +} diff --git a/integration_test/flows/auth_flows.dart b/integration_test/flows/auth_flows.dart new file mode 100644 index 000000000..259906870 --- /dev/null +++ b/integration_test/flows/auth_flows.dart @@ -0,0 +1,41 @@ +import "package:flutter_test/flutter_test.dart"; + +import "../core/test_config.dart"; +import "../robots/auth_page_robot.dart"; + +class AuthFlows { + AuthFlows(this.tester) : _authPageRobot = AuthPageRobot(tester); + + final WidgetTester tester; + final AuthPageRobot _authPageRobot; + + Future authenticateWithPin({List? pin, bool required = true}) async { + final prompted = await _waitForPinWidget( + timeout: required ? const Duration(seconds: 30) : const Duration(seconds: 5), + ); + + if (!prompted) { + expect(required, false, reason: "The pin was never asked for"); + + return; + } + + await _authPageRobot.enterPinCode(pin ?? TestConfig.pin); + } + + bool isPinWidgetShown() => _authPageRobot.onAuthPage(); + + Future _waitForPinWidget({Duration timeout = const Duration(seconds: 30)}) async { + final endTime = DateTime.now().add(timeout); + + while (DateTime.now().isBefore(endTime)) { + await tester.pump(const Duration(milliseconds: 100)); + + if (_authPageRobot.onAuthPage()) { + return true; + } + } + + return false; + } +} diff --git a/integration_test/flows/funds_flows.dart b/integration_test/flows/funds_flows.dart new file mode 100644 index 000000000..f33d2ee2d --- /dev/null +++ b/integration_test/flows/funds_flows.dart @@ -0,0 +1,155 @@ +import "package:cake_wallet/di.dart"; +import "package:cake_wallet/store/app_store.dart"; +import "package:cw_core/sync_status.dart"; +import "package:cw_core/wallet_type.dart"; +import "package:flutter/material.dart"; +import "package:flutter_test/flutter_test.dart"; + +import "../core/test_config.dart"; +import "../core/test_wallets.dart"; +import "../robots/home_page_robot.dart"; +import "../robots/new_dashboard_robot.dart"; +import "../robots/new_send_page_robot.dart"; +import "onboarding_flows.dart"; + +class FundsFlows { + FundsFlows(this.tester) + : _onboardingFlows = OnboardingFlows(tester), + _dashboardRobot = NewDashboardRobot(tester), + _homePageRobot = HomePageRobot(tester), + _sendRobot = NewSendPageRobot(tester); + + final WidgetTester tester; + final OnboardingFlows _onboardingFlows; + final NewDashboardRobot _dashboardRobot; + final HomePageRobot _homePageRobot; + final NewSendPageRobot _sendRobot; + + bool _hasRestoredAnyWallet = false; + + Future openFundedWallet(WalletType type) async { + final wallets = TestWallets.fundedWalletsFor(type); + + for (int index = 0; index < wallets.length; index++) { + final wallet = wallets[index]; + + tester.printToConsole("Trying funded wallet ${index + 1}/${wallets.length} for ${type.name}"); + + if (_hasRestoredAnyWallet) { + await _dashboardRobot.openWalletsTab(); + + final isWalletListReady = await _dashboardRobot.pumpUntil( + () => + tester.any(find.byKey(const ValueKey("wallet_list_page_restore_wallet_button_key"))), + timeout: const Duration(seconds: 60), + ); + + expect( + isWalletListReady, + true, + reason: "The wallet list restore button didn't show before ${type.name}, " + "Currently have: ${_dashboardRobot.describeScreen()} on screen", + ); + + await _onboardingFlows.restoreAdditionalWalletFromWalletList( + type, + seed: wallet.seed, + passphrase: wallet.passphrase, + ); + } else { + await _onboardingFlows.restoreFirstWalletFromSeed( + type, + seed: wallet.seed, + passphrase: wallet.passphrase, + ); + } + + _hasRestoredAnyWallet = true; + + await _waitForDashboard(type); + await _homePageRobot.isDisplayed(); + + if (await _hasSpendableBalance()) { + return true; + } + + tester.printToConsole("Wallet ${index + 1} for ${type.name} has no spendable balance"); + } + + return false; + } + + Future returnToHome({int maxPops = 6}) async { + for (int attempt = 0; attempt <= maxPops; attempt++) { + if (await _homePageRobot.pumpUntil( + () => _isHomeInFront, + timeout: const Duration(seconds: 3), + )) { + return true; + } + + try { + await _homePageRobot.dismissModal(); + } catch (e) { + tester.printToConsole("Nothing left to pop while returning home: $e"); + break; + } + } + + return _isHomeInFront; + } + + Future _waitForDashboard(WalletType type) async { + final reached = await _dashboardRobot.pumpUntil( + () => tester.any(find.byKey(const ValueKey("new_dashboard_page_key"))), + timeout: TestConfig.walletSyncBudget, + ); + + if (reached) { + return; + } + + final status = getIt.get().wallet?.syncStatus; + final stillSyncing = status is SyncingSyncStatus || status is SyncronizingSyncStatus; + + throw TestFailure( + stillSyncing + ? "${type.name} never became ready to send, it is still syncing (${status.runtimeType})" + : "${type.name} never reached the dashboard, its status was ${status.runtimeType}", + ); + } + + bool get _isHomeInFront => _homePageRobot.isInFront && !_sendRobot.isSendFlowOpen; + + Future _hasSpendableBalance({Duration timeout = TestConfig.walletSyncBudget}) async { + final appStore = getIt.get(); + final endTime = DateTime.now().add(timeout); + DateTime? syncedSince; + + while (DateTime.now().isBefore(endTime)) { + await tester.pump(const Duration(milliseconds: 250)); + + final wallet = appStore.wallet; + + if (wallet == null) { + continue; + } + + if (wallet.balance.values.any((balance) => balance.available.sign > 0)) { + return true; + } + + if (wallet.syncStatus is SyncedSyncStatus) { + syncedSince ??= DateTime.now(); + + if (DateTime.now().difference(syncedSince) > const Duration(seconds: 15)) { + return false; + } + } else { + syncedSince = null; + } + } + + return false; + } +} diff --git a/integration_test/flows/onboarding_flows.dart b/integration_test/flows/onboarding_flows.dart new file mode 100644 index 000000000..6216c68b0 --- /dev/null +++ b/integration_test/flows/onboarding_flows.dart @@ -0,0 +1,267 @@ +import "dart:io"; + +import "package:cake_wallet/entities/seed_type.dart"; +import "package:cake_wallet/reactions/wallet_utils.dart"; +import "package:cw_core/wallet_type.dart"; +import "package:flutter_test/flutter_test.dart"; + +import "../core/test_config.dart"; +import "../core/test_wallets.dart"; +import "../robots/create_pin_welcome_page_robot.dart"; +import "../robots/lightning_username_page_robot.dart"; +import "../robots/new_wallet_page_robot.dart"; +import "../robots/new_wallet_type_page_robot.dart"; +import "../robots/pre_seed_page_robot.dart"; +import "../robots/restore_from_seed_or_key_robot.dart"; +import "../robots/restore_options_page_robot.dart"; +import "../robots/seed_verification_page_robot.dart"; +import "../robots/setup_pin_code_robot.dart"; +import "../robots/wallet_group_description_page_robot.dart"; +import "../robots/wallet_group_existing_seed_page_robot.dart"; +import "../robots/wallet_groups_display_page_robot.dart"; +import "../robots/wallet_list_page_robot.dart"; +import "../robots/wallet_seed_page_robot.dart"; +import "../robots/welcome_page_robot.dart"; + +class OnboardingFlows { + OnboardingFlows(this.tester) + : _welcomePageRobot = WelcomePageRobot(tester), + _preSeedPageRobot = PreSeedPageRobot(tester), + _setupPinCodeRobot = SetupPinCodeRobot(tester), + _newWalletPageRobot = NewWalletPageRobot(tester), + _walletSeedPageRobot = WalletSeedPageRobot(tester), + _walletListPageRobot = WalletListPageRobot(tester), + _newWalletTypePageRobot = NewWalletTypePageRobot(tester), + _seedVerificationPageRobot = SeedVerificationPageRobot(tester), + _createPinWelcomePageRobot = CreatePinWelcomePageRobot(tester), + _restoreOptionsPageRobot = RestoreOptionsPageRobot(tester), + _restoreFromSeedOrKeysPageRobot = RestoreFromSeedOrKeysPageRobot(tester), + _lightningUsernamePageRobot = LightningUsernamePageRobot(tester), + _walletGroupDescriptionPageRobot = WalletGroupDescriptionPageRobot(tester), + _walletGroupsDisplayPageRobot = WalletGroupsDisplayPageRobot(tester), + _walletGroupExistingSeedPageRobot = WalletGroupExistingSeedPageRobot(tester); + + final WidgetTester tester; + + final WelcomePageRobot _welcomePageRobot; + final PreSeedPageRobot _preSeedPageRobot; + final SetupPinCodeRobot _setupPinCodeRobot; + final NewWalletPageRobot _newWalletPageRobot; + final WalletSeedPageRobot _walletSeedPageRobot; + final WalletListPageRobot _walletListPageRobot; + final NewWalletTypePageRobot _newWalletTypePageRobot; + final SeedVerificationPageRobot _seedVerificationPageRobot; + final CreatePinWelcomePageRobot _createPinWelcomePageRobot; + final RestoreOptionsPageRobot _restoreOptionsPageRobot; + final RestoreFromSeedOrKeysPageRobot _restoreFromSeedOrKeysPageRobot; + final LightningUsernamePageRobot _lightningUsernamePageRobot; + final WalletGroupDescriptionPageRobot _walletGroupDescriptionPageRobot; + final WalletGroupsDisplayPageRobot _walletGroupsDisplayPageRobot; + final WalletGroupExistingSeedPageRobot _walletGroupExistingSeedPageRobot; + + Future createFirstWallet(WalletType type, {List? pin}) async { + await _createPinWelcomePageRobot.tapSetAPinButton(); + + await setupPinCode(pin ?? TestConfig.pin); + + await _welcomePageRobot.navigateToCreateNewWalletPage(); + + await _selectWalletType(type); + + if (_welcomePageRobot.hasNewSingleSeedButton()) { + await _welcomePageRobot.tapNewSingleSeed(); + } + + await _completeWalletCreationSteps(type); + } + + Future createAdditionalWalletFromWalletList(WalletType type) async { + tester.printToConsole("Creating ${type.name} wallet"); + + await startCreatingWalletFromWalletList(type); + + await _completeWalletCreationSteps(type); + } + + Future createWalletInGroupOf(WalletType type, String existingWalletName) async { + tester.printToConsole("Adding a ${type.name} wallet to the seed of $existingWalletName"); + + await _walletListPageRobot.navigateToCreateNewWalletPage(); + + await _selectWalletType(type); + + await _walletGroupDescriptionPageRobot.isDisplayed(); + await _walletGroupDescriptionPageRobot.navigateToChooseWalletGroup(); + + await _walletGroupsDisplayPageRobot.isDisplayed(); + await _walletGroupsDisplayPageRobot.selectWallet(existingWalletName); + await _walletGroupsDisplayPageRobot.continueWithSelectedSeed(); + + await _newWalletPageRobot.isDisplayed(); + await _newWalletPageRobot.generateWalletName(); + await _newWalletPageRobot.onNextButtonPressed(); + + await _walletGroupExistingSeedPageRobot.isDisplayed(); + await _walletGroupExistingSeedPageRobot.openWallet(); + } + + Future startCreatingWalletFromWalletList(WalletType type) async { + await _walletListPageRobot.navigateToCreateNewWalletPage(); + + await _selectWalletType(type); + + if (isBIP39Wallet(type)) { + await _walletGroupDescriptionPageRobot.isDisplayed(); + await _walletGroupDescriptionPageRobot.navigateToCreateNewSeedPage(); + } + + await _newWalletPageRobot.isDisplayed(); + } + + Future restoreFirstWalletFromSeed( + WalletType type, { + String? seed, + String passphrase = "", + List? pin, + }) async { + await startRestoringFirstWallet(type, pin: pin); + + await _restoreFromSeed(type, seed ?? TestWallets.seedFor(type), passphrase); + } + + Future startRestoringFirstWallet(WalletType type, {List? pin}) async { + await _createPinWelcomePageRobot.tapSetAPinButton(); + + await setupPinCode(pin ?? TestConfig.pin); + + await _welcomePageRobot.navigateToRestoreWalletPage(); + + // Desktop skips the restore options page and goes straight to the type list. + if (!Platform.isLinux) { + await _restoreOptionsPageRobot.navigateToRestoreFromSeedsOrKeysPage(); + } + + await _selectWalletType(type); + + await _restoreFromSeedOrKeysPageRobot.isDisplayed(); + } + + Future restoreAdditionalWalletFromWalletList( + WalletType type, { + String? seed, + String passphrase = "", + }) async { + tester.printToConsole("Restoring ${type.name} wallet"); + + await _walletListPageRobot.navigateToRestoreWalletOptionsPage(); + + if (!Platform.isLinux) { + await _restoreOptionsPageRobot.navigateToRestoreFromSeedsOrKeysPage(); + } + + await _selectWalletType(type); + + await _restoreFromSeed(type, seed ?? TestWallets.seedFor(type), passphrase); + } + + Future _restoreFromSeed(WalletType type, String seed, String passphrase) async { + await _restoreFromSeedOrKeysPageRobot.selectWalletNameFromAvailableOptions(); + await _restoreFromSeedOrKeysPageRobot.enterSeedPhraseForWalletRestore(seed); + + if (passphrase.isNotEmpty) { + await _restoreFromSeedOrKeysPageRobot.enablePassphrase(); + } + + // 25 word monero seeds are legacy seeds and need their restore block height entered. + if (seed.split(" ").length == 25 && type == WalletType.monero) { + await _restoreFromSeedOrKeysPageRobot + .chooseSeedTypeForMoneroOrWowneroWallets(MoneroSeedType.legacy); + await _restoreFromSeedOrKeysPageRobot + .enterBlockHeightForWalletRestore(TestWallets.moneroRestoreBlockHeight); + } + + if (type == WalletType.zcash && TestWallets.zcashRestoreHeight.isNotEmpty) { + await _restoreFromSeedOrKeysPageRobot + .enterBlockHeightForWalletRestore(TestWallets.zcashRestoreHeight); + } + + if (Platform.isLinux) { + final password = TestConfig.pin.join(""); + await _restoreFromSeedOrKeysPageRobot.enterPasswordForWalletRestore(password); + await _restoreFromSeedOrKeysPageRobot.enterPasswordRepeatForWalletRestore(password); + } + + await _restoreFromSeedOrKeysPageRobot.onRestoreWalletButtonPressed(); + + if (passphrase.isNotEmpty) { + await _restoreFromSeedOrKeysPageRobot.enterPassphrase(passphrase); + } + } + + Future setupPinCode(List pin) async { + // Desktop builds take a wallet password on the new wallet form instead of a pin. + if (Platform.isLinux) { + return; + } + + await _setupPinCodeRobot.isDisplayed(); + + await _setupPinCodeRobot.enterPinCode(pin); + await _setupPinCodeRobot.enterPinCode(pin); + await _setupPinCodeRobot.tapSuccessButton(); + } + + Future _selectWalletType(WalletType type) async { + await _newWalletTypePageRobot.isDisplayed(); + + await _newWalletTypePageRobot.findParticularWalletTypeInScrollableList(type); + await _newWalletTypePageRobot.selectWalletType(type); + } + + Future _completeWalletCreationSteps(WalletType type) async { + await _generateNewWalletDetails(); + + await _preSeedPageRobot.isDisplayed(); + await _preSeedPageRobot.onConfirmButtonPressed(); + + await _confirmWalletSeedDetails(); + + await _verifyWalletSeed(); + + if (type == WalletType.bitcoin) { + await _lightningUsernamePageRobot.isDisplayed(); + await _lightningUsernamePageRobot.tapSkip(); + } + } + + Future _generateNewWalletDetails() async { + await _newWalletPageRobot.isDisplayed(); + + await _newWalletPageRobot.generateWalletName(); + + if (Platform.isLinux) { + final password = TestConfig.pin.join(""); + await _restoreFromSeedOrKeysPageRobot.enterPasswordForWalletRestore(password); + await _restoreFromSeedOrKeysPageRobot.enterPasswordRepeatForWalletRestore(password); + } + + await _newWalletPageRobot.onNextButtonPressed(); + } + + Future _confirmWalletSeedDetails() async { + await _walletSeedPageRobot.isDisplayed(); + + _walletSeedPageRobot.confirmWalletDetailsDisplayCorrectly(); + _walletSeedPageRobot.confirmWalletSeedReminderDisplays(); + + await _walletSeedPageRobot.onVerifySeedButtonPressed(); + } + + Future _verifyWalletSeed() async { + await _seedVerificationPageRobot.isDisplayed(); + + _seedVerificationPageRobot.hasTitle(); + + await _seedVerificationPageRobot.verifyWalletSeeds(); + } +} diff --git a/integration_test/flows/wallet_flows.dart b/integration_test/flows/wallet_flows.dart new file mode 100644 index 000000000..953c802c4 --- /dev/null +++ b/integration_test/flows/wallet_flows.dart @@ -0,0 +1,59 @@ +import "package:cake_wallet/di.dart"; +import "package:cake_wallet/store/app_store.dart"; +import "package:flutter/material.dart"; +import "package:flutter_test/flutter_test.dart"; + +import "../robots/new_dashboard_robot.dart"; +import "auth_flows.dart"; + +class WalletFlows { + WalletFlows(this.tester) + : _authFlows = AuthFlows(tester), + _dashboardRobot = NewDashboardRobot(tester); + + final WidgetTester tester; + final AuthFlows _authFlows; + final NewDashboardRobot _dashboardRobot; + + Future _expandWalletGroups({int maxGroups = 10}) async { + for (int index = 0; index < maxGroups; index++) { + final tile = find.byKey(ValueKey("group_wallets_expansion_tile_widget_$index")); + + if (!tester.any(tile)) { + return; + } + + await tester.tap(tile, warnIfMissed: false); + await tester.pump(const Duration(milliseconds: 300)); + } + } + + Future switchToWallet(String name) async { + await _dashboardRobot.openWalletsTab(); + + final walletFinder = find.text(name); + + // Wallets sharing a seed are listed under their group's name, so the wallet itself is + // not on screen until that group is opened. + if (!tester.any(walletFinder)) { + await _expandWalletGroups(); + } + + await _dashboardRobot.pumpUntilFound(walletFinder); + + await tester.tap(walletFinder.first, warnIfMissed: false); + await tester.pump(const Duration(milliseconds: 500)); + + await _authFlows.authenticateWithPin(required: false); + + // The row does nothing at all when that wallet is already the open one, and the load + // itself runs behind a progress overlay, so the store is the only thing that tells us + // the switch happened. + final switched = await _dashboardRobot.pumpUntil( + () => getIt.get().wallet?.name == name, + timeout: const Duration(minutes: 2), + ); + + expect(switched, true, reason: "Wallet never became $name after tapping its row"); + } +} diff --git a/integration_test/funds_related_tests.dart b/integration_test/funds_related_tests.dart deleted file mode 100644 index 4638af80b..000000000 --- a/integration_test/funds_related_tests.dart +++ /dev/null @@ -1,96 +0,0 @@ -import 'package:flutter/foundation.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:integration_test/integration_test.dart'; - -import 'components/common_test_constants.dart'; -import 'components/common_test_flows.dart'; -import 'robots/auth_page_robot.dart'; -import 'robots/dashboard_page_robot.dart'; -import 'robots/exchange_confirm_page_robot.dart'; -import 'robots/exchange_page_robot.dart'; -import 'robots/exchange_trade_page_robot.dart'; -import 'package:cake_wallet/.secrets.g.dart' as secrets; - -void main() { - IntegrationTestWidgetsFlutterBinding.ensureInitialized(); - - DashboardPageRobot dashboardPageRobot; - ExchangePageRobot exchangePageRobot; - ExchangeConfirmPageRobot exchangeConfirmPageRobot; - AuthPageRobot authPageRobot; - ExchangeTradePageRobot exchangeTradePageRobot; - CommonTestFlows commonTestFlows; - - group('Startup Test', () { - testWidgets('Test for Exchange flow using Restore Wallet - Exchanging USDT(Sol) to SOL', - (tester) async { - authPageRobot = AuthPageRobot(tester); - exchangePageRobot = ExchangePageRobot(tester); - dashboardPageRobot = DashboardPageRobot(tester); - exchangeTradePageRobot = ExchangeTradePageRobot(tester); - exchangeConfirmPageRobot = ExchangeConfirmPageRobot(tester); - commonTestFlows = CommonTestFlows(tester); - - await commonTestFlows.startAppFlow(ValueKey('funds_exchange_test_app_key')); - - await commonTestFlows.welcomePageToRestoreWalletThroughSeedsFlow( - CommonTestConstants.testWalletType, - secrets.solanaTestWalletSeeds, - CommonTestConstants.pin, - ); - - // ----------- RestoreFromSeedOrKeys Page ------------- - await dashboardPageRobot.navigateToExchangePage(); - - // ----------- Exchange Page ------------- - await exchangePageRobot.isExchangePage(); - exchangePageRobot.hasResetButton(); - await exchangePageRobot.displayBothExchangeCards(); - exchangePageRobot.confirmRightComponentsDisplayOnDepositExchangeCards(); - exchangePageRobot.confirmRightComponentsDisplayOnReceiveExchangeCards(); - - await exchangePageRobot - .selectDepositCurrency(CommonTestConstants.exchangeTestDepositCurrency); - await exchangePageRobot - .selectReceiveCurrency(CommonTestConstants.exchangeTestReceiveCurrency); - - await exchangePageRobot.enterDepositAmount(CommonTestConstants.exchangeTestAmount); - await exchangePageRobot.enterDepositRefundAddress( - depositAddress: CommonTestConstants.testWalletAddress); - - await exchangePageRobot.enterReceiveAddress(CommonTestConstants.testWalletAddress); - - await exchangePageRobot.onExchangeButtonPressed(); - - await exchangePageRobot.handleErrors(CommonTestConstants.exchangeTestAmount); - - final onAuthPage = authPageRobot.onAuthPage(); - if (onAuthPage) { - await authPageRobot.enterPinCode(CommonTestConstants.pin); - } - - final onAuthPageDesktop = authPageRobot.onAuthPageDesktop(); - if (onAuthPageDesktop) { - await authPageRobot.enterPassword(CommonTestConstants.pin.join("")); - } - - // ----------- Exchange Confirm Page ------------- - await exchangeConfirmPageRobot.isExchangeConfirmPage(); - - exchangeConfirmPageRobot.confirmComponentsOfTradeDisplayProperly(); - await exchangeConfirmPageRobot.confirmCopyTradeIdToClipBoardWorksProperly(); - await exchangeConfirmPageRobot.onSavedTradeIdButtonPressed(); - - // ----------- Exchange Trade Page ------------- - await exchangeTradePageRobot.isExchangeTradePage(); - exchangeTradePageRobot.hasInformationDialog(); - await exchangeTradePageRobot.onGotItButtonPressed(); - - await exchangeTradePageRobot.onSendFromCakeButtonPressed(); - - await exchangeTradePageRobot.handleConfirmSendResult(); - - await exchangeTradePageRobot.onSendButtonOnConfirmSendingDialogPressed(); - }); - }); -} diff --git a/integration_test/funds_suites/send_dry_run_test.dart b/integration_test/funds_suites/send_dry_run_test.dart new file mode 100644 index 000000000..4935fe9af --- /dev/null +++ b/integration_test/funds_suites/send_dry_run_test.dart @@ -0,0 +1,107 @@ +import "package:cake_wallet/di.dart"; +import "package:cake_wallet/store/app_store.dart"; +import "package:flutter_test/flutter_test.dart"; +import "package:integration_test/integration_test.dart"; + +import "../core/app_launcher.dart"; +import "../core/funds_outcome.dart"; +import "../core/test_config.dart"; +import "../flows/auth_flows.dart"; +import "../flows/funds_flows.dart"; +import "../robots/home_page_robot.dart"; +import "../robots/new_send_page_robot.dart"; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + integrationTest("Every funded chain can build a transaction without sending it", (tester) async { + final appLauncher = AppLauncher(tester); + final authFlows = AuthFlows(tester); + final fundsFlows = FundsFlows(tester); + final homePageRobot = HomePageRobot(tester); + final sendRobot = NewSendPageRobot(tester); + + if (!TestConfig.shouldDryRun("send")) { + tester.printToConsole("FLOWS excludes send, skipping"); + return; + } + + final walletTypes = TestConfig.fundedWalletTypesUnderTest; + + expect(walletTypes.isNotEmpty, true, reason: "No funded wallet seeds configured"); + + await appLauncher.launchApp(testKey: "send_dry_run_test_app_key"); + + final appStore = getIt.get(); + final failures = []; + + for (final type in walletTypes) { + tester.printToConsole("Send dry run starting for ${type.name}"); + + try { + final opened = await fundsFlows.openFundedWallet(type); + + if (!opened) { + tester.printToConsole("${type.name} has nothing spendable, top it up"); + FundsOutcome.empty(tester, type.name, "every funded wallet is empty"); + continue; + } + + expect(appStore.wallet?.type, type); + + final ownAddress = appStore.wallet!.walletAddresses.address; + + await homePageRobot.openSendSheet(); + await sendRobot.isDisplayed(); + + await sendRobot.enterAddress(ownAddress); + await sendRobot.enterAmount(TestConfig.fundsSendAmountFor(type)); + await sendRobot.tapSendButtonWhenReady(); + + await authFlows.authenticateWithPin(required: false); + + await sendRobot.confirmTransactionBuilt(); + + await sendRobot.confirmSendButtonReplacesSwiper(); + + tester.printToConsole("${type.name} built a transaction, leaving it unsent"); + FundsOutcome.ok(tester, type.name, "built and signed, not broadcast"); + + expect( + await fundsFlows.returnToHome(), + true, + reason: "${type.name} finished but the send flow would not close", + ); + } catch (e) { + if (FundsOutcome.needsFurtherReview(e.toString())) { + failures.add("${type.name}: $e"); + } else { + tester.printToConsole("${type.name} needs attention rather than a fix: $e"); + } + + FundsOutcome.failed(tester, type.name, e.toString()); + + if (!await fundsFlows.returnToHome()) { + // The app is hanging or found itself somewhere we don't know, + // no need to continue running anything else + for (final skipped in walletTypes.skip(walletTypes.indexOf(type) + 1)) { + FundsOutcome.waiting( + tester, + skipped.name, + "never ran, ${type.name} left the app somewhere it could not come back from", + ); + } + + if (FundsOutcome.needsFurtherReview(e.toString())) { + failures.add("${type.name}: could not get back to the home page, " + "the chains after this one never ran"); + } + + break; + } + } + } + + expect(failures, isEmpty, reason: "Failed chains:\n${failures.join("\n")}"); + }); +} diff --git a/integration_test/funds_suites/send_funds_test.dart b/integration_test/funds_suites/send_funds_test.dart new file mode 100644 index 000000000..47ff44db5 --- /dev/null +++ b/integration_test/funds_suites/send_funds_test.dart @@ -0,0 +1,109 @@ +import "package:cake_wallet/di.dart"; +import "package:cake_wallet/store/app_store.dart"; +import "package:flutter_test/flutter_test.dart"; +import "package:integration_test/integration_test.dart"; + +import "../core/app_launcher.dart"; +import "../core/funds_outcome.dart"; +import "../core/test_config.dart"; +import "../flows/auth_flows.dart"; +import "../flows/funds_flows.dart"; +import "../robots/home_page_robot.dart"; +import "../robots/new_send_page_robot.dart"; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + // tests everything up to the point of swiping + integrationTest("Send a real self transaction on every funded chain", (tester) async { + final appLauncher = AppLauncher(tester); + final fundsFlows = FundsFlows(tester); + final authFlows = AuthFlows(tester); + final homePageRobot = HomePageRobot(tester); + final sendRobot = NewSendPageRobot(tester); + + if (!TestConfig.shouldSpend("send")) { + tester.printToConsole("FLOWS excludes send, skipping"); + return; + } + + final walletTypes = TestConfig.fundedWalletTypesUnderTest; + + expect(walletTypes.isNotEmpty, true, reason: "No funded wallet seeds configured"); + + await appLauncher.launchApp(testKey: "send_funds_test_app_key"); + + final appStore = getIt.get(); + final failures = []; + + for (final type in walletTypes) { + tester.printToConsole("Funds send starting for ${type.name}"); + + try { + final opened = await fundsFlows.openFundedWallet(type); + + if (!opened) { + tester.printToConsole("${type.name} has nothing spendable, top it up"); + FundsOutcome.empty(tester, type.name, "every funded wallet is empty"); + continue; + } + + expect(appStore.wallet?.type, type); + + // Making it a self send so the funds remain inside the funded wallet, only fees are spent. + final ownAddress = appStore.wallet!.walletAddresses.address; + + await homePageRobot.openSendSheet(); + await sendRobot.isDisplayed(); + + await sendRobot.enterAddress(ownAddress); + await sendRobot.enterAmount(TestConfig.fundsSendAmountFor(type)); + await sendRobot.tapSendButtonWhenReady(); + + await authFlows.authenticateWithPin(required: false); + + await sendRobot.swipeToConfirm(); + await sendRobot.confirmTransactionCommitted(); + + // The send page sits under the confirm sheet, so one pop leaves it up and the next + // chain would try to restore from behind it. + expect( + await fundsFlows.returnToHome(), + true, + reason: "${type.name} broadcast but the send flow would not close", + ); + + tester.printToConsole("FUNDS_SEND_OK: ${type.name}"); + FundsOutcome.ok(tester, type.name, "self send broadcast"); + } catch (e) { + if (FundsOutcome.needsFurtherReview(e.toString())) { + failures.add("${type.name}: $e"); + } else { + tester.printToConsole("${type.name} needs attention rather than a fix: $e"); + } + + FundsOutcome.failed(tester, type.name, e.toString()); + tester.printToConsole("FUNDS_SEND_FAIL: ${type.name}: $e"); + + if (!await fundsFlows.returnToHome()) { + for (final skipped in walletTypes.skip(walletTypes.indexOf(type) + 1)) { + FundsOutcome.waiting( + tester, + skipped.name, + "never ran, ${type.name} left the app somewhere it could not come back from", + ); + } + + if (FundsOutcome.needsFurtherReview(e.toString())) { + failures.add("${type.name}: could not get back to the home page, " + "the chains after this one never ran"); + } + + break; + } + } + } + + expect(failures, isEmpty, reason: "Failed chains:\n${failures.join("\n")}"); + }); +} diff --git a/integration_test/funds_suites/swap_dry_run_test.dart b/integration_test/funds_suites/swap_dry_run_test.dart new file mode 100644 index 000000000..2e31ad57f --- /dev/null +++ b/integration_test/funds_suites/swap_dry_run_test.dart @@ -0,0 +1,69 @@ +import "package:cake_wallet/di.dart"; +import "package:cake_wallet/store/app_store.dart"; +import "package:cw_core/crypto_currency.dart"; +import "package:flutter_test/flutter_test.dart"; +import "package:integration_test/integration_test.dart"; + +import "../core/app_launcher.dart"; +import "../core/funds_outcome.dart"; +import "../core/test_config.dart"; +import "../flows/funds_flows.dart"; +import "../robots/home_page_robot.dart"; +import "../robots/new_swap_page_robot.dart"; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + // tests everything up to the point of creating the trade + integrationTest("Swap prices a real deposit without creating a trade", (tester) async { + final appLauncher = AppLauncher(tester); + final fundsFlows = FundsFlows(tester); + final homePageRobot = HomePageRobot(tester); + final swapRobot = NewSwapPageRobot(tester); + + if (!TestConfig.shouldDryRun("swap")) { + tester.printToConsole("FLOWS excludes swap, skipping"); + return; + } + + final walletTypes = TestConfig.fundedWalletTypesUnderTest; + + expect(walletTypes.isNotEmpty, true, reason: "No funded wallet seeds configured"); + + await appLauncher.launchApp(testKey: "swap_dry_run_test_app_key"); + + final appStore = getIt.get(); + + final type = TestConfig.swapDepositType(walletTypes); + + expect( + walletTypes, + contains(type), + reason: "SWAP_FROM=${type.name} is not among the funded wallets under test", + ); + + final opened = await fundsFlows.openFundedWallet(type); + + expect(opened, true, reason: "Every funded wallet for ${type.name} came up empty"); + expect(appStore.wallet?.type, type); + + await homePageRobot.openSwapSheet(); + await swapRobot.isDisplayed(); + + if (TestConfig.swapReceive.isNotEmpty) { + await swapRobot.chooseReceiveCurrency(CryptoCurrency.fromString(TestConfig.swapReceive)); + } + + await swapRobot.enterMinimumViableDepositAmount(); + await swapRobot.enterTestReceiveAddress(); + + await swapRobot.confirmQuoteReceived(); + await swapRobot.confirmSwapButtonEnabled(); + + tester.printToConsole("${type.name} got a quote and the swap button enabled, no trade created"); + FundsOutcome.ok(tester, type.name, "quoted, swap button enabled, no trade created"); + + await swapRobot.dismissModal(); + await homePageRobot.isDisplayed(); + }); +} diff --git a/integration_test/funds_suites/swap_funds_test.dart b/integration_test/funds_suites/swap_funds_test.dart new file mode 100644 index 000000000..56e02f5c1 --- /dev/null +++ b/integration_test/funds_suites/swap_funds_test.dart @@ -0,0 +1,86 @@ +import "package:cake_wallet/di.dart"; +import "package:cake_wallet/store/app_store.dart"; +import "package:cw_core/crypto_currency.dart"; +import "package:flutter_test/flutter_test.dart"; +import "package:integration_test/integration_test.dart"; + +import "../core/app_launcher.dart"; +import "../core/funds_outcome.dart"; +import "../core/test_config.dart"; +import "../core/test_wallets.dart"; +import "../flows/auth_flows.dart"; +import "../flows/funds_flows.dart"; +import "../robots/home_page_robot.dart"; +import "../robots/new_send_page_robot.dart"; +import "../robots/new_swap_page_robot.dart"; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + integrationTest("Create a real swap and broadcast its deposit", (tester) async { + final appLauncher = AppLauncher(tester); + final fundsFlows = FundsFlows(tester); + final authFlows = AuthFlows(tester); + final homePageRobot = HomePageRobot(tester); + final sendRobot = NewSendPageRobot(tester); + final swapRobot = NewSwapPageRobot(tester); + + if (!TestConfig.shouldSpend("swap")) { + tester.printToConsole("FLOWS excludes swap, skipping"); + return; + } + + final walletTypes = TestConfig.fundedWalletTypesUnderTest; + + expect(walletTypes.isNotEmpty, true, reason: "No funded wallet seeds configured"); + + final type = TestConfig.swapDepositType(walletTypes); + + expect( + walletTypes, + contains(type), + reason: "SWAP_FROM=${type.name} is not among the funded wallets under test", + ); + + await appLauncher.launchApp(testKey: "swap_funds_test_app_key"); + + final opened = await fundsFlows.openFundedWallet(type); + + expect( + opened, + true, + reason: "${type.name}: none of the ${TestWallets.fundedWalletsFor(type).length} funded " + "wallets has a spendable balance, top them up before the next run", + ); + + final appStore = getIt.get(); + expect(appStore.wallet?.type, type); + + await homePageRobot.openSwapSheet(); + await swapRobot.isDisplayed(); + + if (TestConfig.swapReceive.isNotEmpty) { + await swapRobot.chooseReceiveCurrency(CryptoCurrency.fromString(TestConfig.swapReceive)); + } + + await swapRobot.enterMinimumViableDepositAmount(); + final payoutAddress = await swapRobot.enterTestReceiveAddress(); + await swapRobot.confirmQuoteReceived(); + await swapRobot.confirmSwapButtonEnabled(); + + await swapRobot.tapSwapButton(); + + await authFlows.authenticateWithPin(required: false); + + await swapRobot.confirmTradeCreated(); + swapRobot.confirmPayoutAddress(payoutAddress); + + await sendRobot.swipeToConfirm(); + await swapRobot.confirmDepositCommitted(); + + await homePageRobot.isDisplayed(); + + tester.printToConsole("FUNDS_SWAP_OK: ${type.name}"); + FundsOutcome.ok(tester, type.name, "trade created and deposit broadcast"); + }); +} diff --git a/integration_test/helpers/mocks.dart b/integration_test/helpers/mocks.dart deleted file mode 100644 index b36ff314f..000000000 --- a/integration_test/helpers/mocks.dart +++ /dev/null @@ -1,27 +0,0 @@ -import 'package:cake_wallet/core/auth_service.dart'; -import 'package:cake_wallet/core/secure_storage.dart'; -import 'package:cake_wallet/store/app_store.dart'; -import 'package:cake_wallet/store/authentication_store.dart'; -import 'package:cake_wallet/store/settings_store.dart'; -import 'package:cake_wallet/store/wallet_list_store.dart'; -import 'package:cake_wallet/view_model/link_view_model.dart'; -import 'package:hive/hive.dart'; -import 'package:mocktail/mocktail.dart'; - -class MockAppStore extends Mock implements AppStore {} - -class MockAuthService extends Mock implements AuthService {} - -class MockSettingsStore extends Mock implements SettingsStore {} - -class MockAuthenticationStore extends Mock implements AuthenticationStore {} - -class MockWalletListStore extends Mock implements WalletListStore {} - -class MockLinkViewModel extends Mock implements LinkViewModel {} - -class MockHiveInterface extends Mock implements HiveInterface {} - -class MockHiveBox extends Mock implements Box {} - -class MockSecureStorage extends Mock implements SecureStorage {} diff --git a/integration_test/helpers/test_helpers.dart b/integration_test/helpers/test_helpers.dart deleted file mode 100644 index caa4ebd27..000000000 --- a/integration_test/helpers/test_helpers.dart +++ /dev/null @@ -1,99 +0,0 @@ -import 'package:cake_wallet/core/auth_service.dart'; -import 'package:cake_wallet/core/secure_storage.dart'; -import 'package:cake_wallet/di.dart'; -import 'package:cake_wallet/store/app_store.dart'; -import 'package:cake_wallet/store/authentication_store.dart'; -import 'package:cake_wallet/store/settings_store.dart'; -import 'package:cake_wallet/store/wallet_list_store.dart'; -import 'package:cake_wallet/view_model/link_view_model.dart'; -import 'package:hive/hive.dart'; -import 'package:mocktail/mocktail.dart'; - -import 'mocks.dart'; - -class TestHelpers { - static void setup() { - // Fallback values can also be declared here - registerDependencies(); - } - - static void registerDependencies() { - getAndRegisterAppStore(); - getAndRegisterAuthService(); - getAndRegisterSettingsStore(); - getAndRegisterAuthenticationStore(); - getAndRegisterWalletListStore(); - - getAndRegisterLinkViewModel(); - getAndRegisterSecureStorage(); - getAndRegisterHiveInterface(); - } - - static MockSettingsStore getAndRegisterSettingsStore() { - _removeRegistrationIfExists(); - final service = MockSettingsStore(); - getIt.registerSingleton(service); - return service; - } - - static MockAppStore getAndRegisterAppStore() { - _removeRegistrationIfExists(); - final service = MockAppStore(); - final settingsStore = getAndRegisterSettingsStore(); - - when(() => service.settingsStore).thenAnswer((invocation) => settingsStore); - getIt.registerSingleton(service); - return service; - } - - static MockAuthService getAndRegisterAuthService() { - _removeRegistrationIfExists(); - final service = MockAuthService(); - getIt.registerSingleton(service); - return service; - } - - static MockAuthenticationStore getAndRegisterAuthenticationStore() { - _removeRegistrationIfExists(); - final service = MockAuthenticationStore(); - when(() => service.state).thenReturn(AuthenticationState.uninitialized); - getIt.registerSingleton(service); - return service; - } - - static MockWalletListStore getAndRegisterWalletListStore() { - _removeRegistrationIfExists(); - final service = MockWalletListStore(); - getIt.registerSingleton(service); - return service; - } - - static MockLinkViewModel getAndRegisterLinkViewModel() { - _removeRegistrationIfExists(); - final service = MockLinkViewModel(); - getIt.registerSingleton(service); - return service; - } - - static MockHiveInterface getAndRegisterHiveInterface() { - _removeRegistrationIfExists(); - final service = MockHiveInterface(); - getIt.registerSingleton(service); - return service; - } - - static MockSecureStorage getAndRegisterSecureStorage() { - _removeRegistrationIfExists(); - final service = MockSecureStorage(); - getIt.registerSingleton(service); - return service; - } - - static void _removeRegistrationIfExists() { - if (getIt.isRegistered()) { - getIt.unregister(); - } - } - - static void tearDown() => getIt.reset(); -} diff --git a/integration_test/integration_response_data.json b/integration_test/integration_response_data.json deleted file mode 100644 index ec747fa47..000000000 --- a/integration_test/integration_response_data.json +++ /dev/null @@ -1 +0,0 @@ -null \ No newline at end of file diff --git a/integration_test/robots/auth_page_robot.dart b/integration_test/robots/auth_page_robot.dart index eac028e4b..8160e6da2 100644 --- a/integration_test/robots/auth_page_robot.dart +++ b/integration_test/robots/auth_page_robot.dart @@ -1,36 +1,20 @@ -import 'package:cake_wallet/generated/i18n.dart'; -import 'package:cake_wallet/src/screens/auth/auth_page.dart'; -import 'package:flutter/widgets.dart'; -import 'package:flutter_test/flutter_test.dart'; +import "package:cake_wallet/src/screens/auth/auth_page.dart"; +import "package:flutter/widgets.dart"; +import "package:flutter_test/flutter_test.dart"; -import '../components/common_test_cases.dart'; -import 'pin_code_widget_robot.dart'; +import "pin_code_widget_robot.dart"; class AuthPageRobot extends PinCodeWidgetRobot { - AuthPageRobot(this.tester) - : commonTestCases = CommonTestCases(tester), - super(tester); + AuthPageRobot(super.tester); - final WidgetTester tester; - late CommonTestCases commonTestCases; + @override + Future isDisplayed() async { + await isSpecificPage(); + } bool onAuthPage() { - final hasPinButtons = find.byKey(ValueKey('pin_code_button_3_key')); + final hasPinButtons = find.byKey(const ValueKey("pin_code_button_3_key")); final hasPin = hasPinButtons.tryEvaluate(); return hasPin; } - - bool onAuthPageDesktop() { - final hasWalletPasswordInput = find.byKey(ValueKey('enter_wallet_password')); - return hasWalletPasswordInput.tryEvaluate(); - } - - Future isAuthPage() async { - await commonTestCases.isSpecificPage(); - await commonTestCases.takeScreenshots('auth_page'); - } - - void hasTitle() { - commonTestCases.hasText(S.current.setup_pin); - } } diff --git a/integration_test/robots/connection_sync_page_robot.dart b/integration_test/robots/connection_sync_page_robot.dart new file mode 100644 index 000000000..a5a9e00d5 --- /dev/null +++ b/integration_test/robots/connection_sync_page_robot.dart @@ -0,0 +1,40 @@ +import "package:cake_wallet/entities/fiat_api_mode.dart"; +import "package:cake_wallet/src/screens/settings/connection_sync_page.dart"; +import "package:flutter/material.dart"; +import "package:flutter_test/flutter_test.dart"; + +import "../core/base_robot.dart"; + +class ConnectionSyncPageRobot extends BaseRobot { + ConnectionSyncPageRobot(super.tester); + + @override + Future isDisplayed() async { + await isSpecificPage(); + } + + Future setFiatApiMode(FiatApiMode mode) async { + await tapByKey("fiat_api"); + + final unselected = find.byKey(ValueKey("picker_items_index_${mode.title}_button_key")); + final selected = + find.byKey(ValueKey("picker_items_index_${mode.title}_selected_item_button_key")); + + await pumpUntil(() => tester.any(unselected) || tester.any(selected)); + + await tester.tap(tester.any(unselected) ? unselected : selected); + + await settle(); + } + + void hasFiatApiMode(FiatApiMode mode) { + expect( + find.descendant( + of: find.byKey(const ValueKey("fiat_api")), + matching: find.text(mode.title), + ), + findsOneWidget, + reason: "The fiat api row does not show ${mode.title}", + ); + } +} diff --git a/integration_test/robots/contact_robot.dart b/integration_test/robots/contact_robot.dart new file mode 100644 index 000000000..57354f8cd --- /dev/null +++ b/integration_test/robots/contact_robot.dart @@ -0,0 +1,66 @@ +import "package:cake_wallet/src/screens/contact/contact_list_page.dart"; +import "package:cake_wallet/src/screens/contact/contact_page.dart"; +import "package:flutter/material.dart"; +import "package:flutter_test/flutter_test.dart"; + +import "../core/base_robot.dart"; + +class ContactRobot extends BaseRobot { + ContactRobot(super.tester); + + @override + Future isDisplayed() async { + await pumpUntilFound(find.byType(ContactListPage)); + } + + Future openAddContact() async { + await tapByKey("contact_list_page_add_contact_button_key"); + + await pumpUntilFound(find.byType(ContactPage)); + } + + Future enterName(String name) async { + await enterTextByKey("contact_page_name_textfield_key", name); + } + + Future chooseCurrency(String currencyName) async { + await tapByKey("contact_page_currency_picker_button_key"); + + await tapByKey("picker_items_index_${currencyName}_button_key"); + + await settle(); + } + + Future enterAddress(String address) async { + final input = find.byKey(const ValueKey("contact_page_address_input_key")); + + await pumpUntilFound(input); + + final field = find.descendant(of: input, matching: find.byType(EditableText)); + + await tester.enterText(field.first, address); + await tester.pump(const Duration(milliseconds: 300)); + } + + Future save() async { + await tapByKey("contact_page_save_button_key"); + + await pumpUntilGone(find.byType(ContactPage)); + + await settle(); + } + + void expectContactSaved(String name, String address) { + final page = tester.widget(find.byType(ContactListPage)); + + final saved = page.contactListViewModel.contacts.where((c) => c.name == name).toList(); + + expect(saved, isNotEmpty, reason: "The contact $name was never saved"); + + expect( + saved.first.address, + address, + reason: "The address book stored a different address than it was given", + ); + } +} diff --git a/integration_test/robots/create_pin_welcome_page_robot.dart b/integration_test/robots/create_pin_welcome_page_robot.dart index 74b268e94..af0537696 100644 --- a/integration_test/robots/create_pin_welcome_page_robot.dart +++ b/integration_test/robots/create_pin_welcome_page_robot.dart @@ -1,46 +1,16 @@ -import 'package:cake_wallet/generated/i18n.dart'; -import 'package:cake_wallet/src/screens/welcome/create_pin_welcome_page.dart'; -import 'package:cake_wallet/wallet_type_utils.dart'; -import 'package:flutter_test/flutter_test.dart'; +import "package:cake_wallet/src/screens/welcome/create_pin_welcome_page.dart"; -import '../components/common_test_cases.dart'; +import "../core/base_robot.dart"; -class CreatePinWelcomePageRobot { - CreatePinWelcomePageRobot(this.tester) : commonTestCases = CommonTestCases(tester); +class CreatePinWelcomePageRobot extends BaseRobot { + CreatePinWelcomePageRobot(super.tester); - final WidgetTester tester; - late CommonTestCases commonTestCases; - - Future isCreatePinWelcomePage() async { - await commonTestCases.isSpecificPage(); - await commonTestCases.takeScreenshots('create_pin_welcome_page'); - } - - void hasTitle() { - String title; - if (isMoneroOnly) { - title = S.current.monero_com; - } - - title = S.current.cake_wallet; - - commonTestCases.hasText(title); - } - - void hasDescription() { - String description; - if (isMoneroOnly) { - description = S.current.monero_com_wallet_text; - } - - description = S.current.new_first_wallet_text; - - commonTestCases.hasText(description); + @override + Future isDisplayed() async { + await isSpecificPage(); } Future tapSetAPinButton() async { - await commonTestCases.tapItemByKey('create_pin_welcome_page_create_a_pin_button_key'); - - await commonTestCases.defaultSleepTime(); + await tapByKey("create_pin_welcome_page_create_a_pin_button_key"); } } diff --git a/integration_test/robots/dashboard_menu_widget_robot.dart b/integration_test/robots/dashboard_menu_widget_robot.dart deleted file mode 100644 index 34c76aa7d..000000000 --- a/integration_test/robots/dashboard_menu_widget_robot.dart +++ /dev/null @@ -1,40 +0,0 @@ -import 'package:cake_wallet/src/screens/dashboard/widgets/menu_widget.dart'; -import 'package:flutter_test/flutter_test.dart'; - -import '../components/common_test_cases.dart'; - -class DashboardMenuWidgetRobot { - DashboardMenuWidgetRobot(this.tester) : commonTestCases = CommonTestCases(tester); - - final WidgetTester tester; - late CommonTestCases commonTestCases; - - Future hasMenuWidget() async { - await commonTestCases.takeScreenshots('menu_widget_page'); - commonTestCases.hasType(); - } - - void displaysTheCorrectWalletNameAndSubName() { - final menuWidgetState = tester.state(find.byType(MenuWidget)); - - final walletName = menuWidgetState.widget.dashboardViewModel.name; - commonTestCases.hasText(walletName); - - final walletSubName = menuWidgetState.widget.dashboardViewModel.subname; - if (walletSubName.isNotEmpty) { - commonTestCases.hasText(walletSubName); - } - } - - Future navigateToWalletMenu() async { - await commonTestCases.tapItemByKey('dashboard_page_menu_widget_wallet_menu_button_key'); - await commonTestCases.defaultSleepTime(); - } - - Future navigateToSecurityAndBackupPage() async { - await commonTestCases.tapItemByKey( - 'dashboard_page_menu_widget_security_and_backup_button_key', - ); - await commonTestCases.defaultSleepTime(); - } -} diff --git a/integration_test/robots/dashboard_page_robot.dart b/integration_test/robots/dashboard_page_robot.dart deleted file mode 100644 index 915faa25c..000000000 --- a/integration_test/robots/dashboard_page_robot.dart +++ /dev/null @@ -1,122 +0,0 @@ -import 'package:cake_wallet/generated/i18n.dart'; -import 'package:cake_wallet/src/screens/dashboard/dashboard_page.dart'; -import 'package:cake_wallet/src/screens/dashboard/pages/balance/crypto_balance_widget.dart'; -import 'package:cw_core/wallet_type.dart'; -import 'package:flutter_test/flutter_test.dart'; - -import '../components/common_test_cases.dart'; -import 'dashboard_menu_widget_robot.dart'; - -class DashboardPageRobot { - DashboardPageRobot(this.tester) - : commonTestCases = CommonTestCases(tester), - dashboardMenuWidgetRobot = DashboardMenuWidgetRobot(tester); - - final WidgetTester tester; - final DashboardMenuWidgetRobot dashboardMenuWidgetRobot; - late CommonTestCases commonTestCases; - - Future isDashboardPage() async { - await commonTestCases.isSpecificPage(); - await commonTestCases.takeScreenshots('dashboard_page'); - } - - Future confirmWalletTypeIsDisplayedCorrectly( - WalletType type, { - bool isHaven = false, - }) async { - final cryptoBalanceWidget = - tester.widget(find.byType(CryptoBalanceWidget)); - final hasAccounts = cryptoBalanceWidget.dashboardViewModel.balanceViewModel.hasAccounts; - - if (hasAccounts) { - final walletName = cryptoBalanceWidget.dashboardViewModel.name; - commonTestCases.hasText(walletName); - } else { - final walletName = walletTypeToString(type); - final assetName = isHaven ? '$walletName Assets' : walletName; - commonTestCases.hasText(assetName); - } - await commonTestCases.defaultSleepTime(seconds: 5); - } - - void confirmServiceUpdateButtonDisplays() { - commonTestCases.hasValueKey('dashboard_page_services_update_button_key'); - } - - void confirmSyncIndicatorButtonDisplays() { - commonTestCases.hasValueKey('dashboard_page_sync_indicator_button_key'); - } - - void confirmMenuButtonDisplays() { - commonTestCases.hasValueKey('dashboard_page_wallet_menu_button_key'); - } - - Future confirmRightCryptoAssetTitleDisplaysPerPageView( - WalletType type, { - bool isHaven = false, - }) async { - //Balance Page - await confirmWalletTypeIsDisplayedCorrectly(type, isHaven: isHaven); - - // Swipe to Cake features Page - await swipeDashboardTab(false); - commonTestCases.hasText('Cake ${S.current.features}'); - - // Swipe back to balance - await swipeDashboardTab(true); - - // Swipe to Transactions Page - await swipeDashboardTab(true); - commonTestCases.hasText(S.current.transactions); - - // Swipe back to balance - await swipeDashboardTab(false); - await commonTestCases.defaultSleepTime(seconds: 3); - } - - Future swipeDashboardTab(bool swipeRight) async { - await commonTestCases.swipeByPageKey( - key: 'dashboard_page_view_key', - swipeRight: swipeRight, - ); - await commonTestCases.defaultSleepTime(); - } - - Future openDrawerMenu() async { - await commonTestCases.tapItemByKey('dashboard_page_wallet_menu_button_key'); - await commonTestCases.defaultSleepTime(); - } - - Future navigateToBuyPage() async { - await commonTestCases.tapItemByKey('dashboard_page_buy_action_button_key'); - } - - Future navigateToWalletsListPage({bool isDrawer = false}) async { - await tester.pumpAndSettle(Duration(milliseconds: 1000)); - - await commonTestCases.tapItemByKey( - isDrawer - ? 'dashboard_page_menu_widget_wallet_menu_button_key' - : 'dashboard_page__wallet_list_button_key', - ); - - await tester.pumpAndSettle(Duration(milliseconds: 2000)); - } - - Future navigateToSendPage() async { - await commonTestCases.tapItemByKey('dashboard_page_send_action_button_key'); - } - - Future navigateToSellPage() async { - await commonTestCases.tapItemByKey('dashboard_page_sell_action_button_key'); - } - - Future navigateToReceivePage() async { - await commonTestCases.tapItemByKey('dashboard_page_receive_action_button_key'); - } - - Future navigateToExchangePage() async { - await commonTestCases.tapItemByKey('dashboard_page_swap_action_button_key'); - } -} diff --git a/integration_test/robots/disclaimer_page_robot.dart b/integration_test/robots/disclaimer_page_robot.dart deleted file mode 100644 index 282169a9c..000000000 --- a/integration_test/robots/disclaimer_page_robot.dart +++ /dev/null @@ -1,40 +0,0 @@ -import 'package:cake_wallet/src/screens/disclaimer/disclaimer_page.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; - -import '../components/common_test_cases.dart'; - -class DisclaimerPageRobot { - DisclaimerPageRobot(this.tester) : commonTestCases = CommonTestCases(tester); - - final WidgetTester tester; - late CommonTestCases commonTestCases; - - Future isDisclaimerPage() async { - await commonTestCases.isSpecificPage(); - await commonTestCases.takeScreenshots('disclaimer_page'); - } - - void hasCheckIcon(bool hasBeenTapped) { - // The checked Icon should not be available initially, until user taps the checkbox - final checkIcon = find.byKey(ValueKey('disclaimer_check_icon_key')); - expect(checkIcon, hasBeenTapped ? findsOneWidget : findsNothing); - } - - void hasDisclaimerCheckbox() { - final checkBox = find.byKey(ValueKey('disclaimer_check_key')); - expect(checkBox, findsOneWidget); - } - - Future tapDisclaimerCheckbox() async { - await commonTestCases.tapItemByKey('disclaimer_check_key'); - - await commonTestCases.defaultSleepTime(); - } - - Future tapAcceptButton() async { - await commonTestCases.tapItemByKey('disclaimer_accept_button_key'); - - await commonTestCases.defaultSleepTime(); - } -} diff --git a/integration_test/robots/display_settings_page_robot.dart b/integration_test/robots/display_settings_page_robot.dart new file mode 100644 index 000000000..d9615be74 --- /dev/null +++ b/integration_test/robots/display_settings_page_robot.dart @@ -0,0 +1,78 @@ +import "package:cake_wallet/entities/fiat_currency.dart"; +import "package:cake_wallet/new-ui/widgets/currency_picker/currency_picker_search_field.dart"; +import "package:cake_wallet/new-ui/widgets/currency_picker/fiat_currency_picker_sheet.dart"; +import "package:cake_wallet/new-ui/widgets/currency_picker/fiat_currency_row.dart"; +import "package:cake_wallet/src/screens/settings/display_settings_page.dart"; +import "package:flutter/material.dart"; +import "package:flutter_test/flutter_test.dart"; + +import "../core/base_robot.dart"; + +class DisplaySettingsPageRobot extends BaseRobot { + DisplaySettingsPageRobot(super.tester); + + @override + Future isDisplayed() async { + await isSpecificPage(); + } + + Future openLanguagePicker() async { + await tapByKey("display_settings_language"); + } + + Future chooseLanguage(String code) async { + await tapByKey("picker_items_index_${code}_button_key"); + + await settle(); + } + + bool hasFiatCurrencyRow() => + tester.any(find.byKey(const ValueKey("display_settings_fiat_currency"))); + + Future openFiatCurrencyPicker() async { + await tapByKey("display_settings_fiat_currency"); + + await pumpUntilFound(find.byType(FiatCurrencyPickerSheet)); + } + + Future chooseFiatCurrency(FiatCurrency currency) async { + final searchField = find.descendant( + of: find.byType(CurrencyPickerSearchField), + matching: find.byType(TextFormField), + ); + + await tester.enterText(searchField, currency.fullName); + + final row = find.byWidgetPredicate( + (widget) => widget is FiatCurrencyRow && widget.currency == currency, + ); + + await pumpUntilFound(row); + + await tester.tap(row.first); + + await pumpUntilGone(find.byType(FiatCurrencyPickerSheet)); + } + + void expectFiatCurrencyShown(FiatCurrency currency) { + expect( + find.descendant( + of: find.byKey(const ValueKey("display_settings_fiat_currency")), + matching: find.text(currency.title), + ), + findsOneWidget, + reason: "The currency row still does not show ${currency.title}", + ); + } + + void expectLanguageShown(String displayName) { + expect( + find.descendant( + of: find.byKey(const ValueKey("display_settings_language")), + matching: find.text(displayName), + ), + findsOneWidget, + reason: "The language row still does not show $displayName", + ); + } +} diff --git a/integration_test/robots/exchange_confirm_page_robot.dart b/integration_test/robots/exchange_confirm_page_robot.dart deleted file mode 100644 index e71979393..000000000 --- a/integration_test/robots/exchange_confirm_page_robot.dart +++ /dev/null @@ -1,46 +0,0 @@ -import 'package:cake_wallet/generated/i18n.dart'; -import 'package:cake_wallet/src/screens/exchange_trade/exchange_confirm_page.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_test/flutter_test.dart'; - -import '../components/common_test_cases.dart'; - -class ExchangeConfirmPageRobot { - ExchangeConfirmPageRobot(this.tester) : commonTestCases = CommonTestCases(tester); - - final WidgetTester tester; - late CommonTestCases commonTestCases; - - Future isExchangeConfirmPage() async { - await commonTestCases.isSpecificPage(); - await commonTestCases.takeScreenshots('exchange_confirm_page'); - } - - void confirmComponentsOfTradeDisplayProperly() { - final ExchangeConfirmPage exchangeConfirmPage = tester.widget(find.byType(ExchangeConfirmPage)); - final trade = exchangeConfirmPage.trade; - - commonTestCases.hasText(trade.id); - commonTestCases.hasText('${trade.provider.title} ${S.current.trade_id}'); - - commonTestCases.hasValueKey('exchange_confirm_page_saved_id_button_key'); - commonTestCases.hasValueKey('exchange_confirm_page_copy_to_clipboard_button_key'); - } - - Future confirmCopyTradeIdToClipBoardWorksProperly() async { - final ExchangeConfirmPage exchangeConfirmPage = tester.widget(find.byType(ExchangeConfirmPage)); - final trade = exchangeConfirmPage.trade; - - await commonTestCases.tapItemByKey('exchange_confirm_page_copy_to_clipboard_button_key'); - - ClipboardData? clipboardData = await Clipboard.getData('text/plain'); - - expect(clipboardData?.text, trade.id); - } - - Future onSavedTradeIdButtonPressed() async { - await tester.pumpAndSettle(); - await commonTestCases.defaultSleepTime(); - await commonTestCases.tapItemByKey('exchange_confirm_page_saved_id_button_key'); - } -} diff --git a/integration_test/robots/exchange_page_robot.dart b/integration_test/robots/exchange_page_robot.dart deleted file mode 100644 index b0c9523cc..000000000 --- a/integration_test/robots/exchange_page_robot.dart +++ /dev/null @@ -1,338 +0,0 @@ -import 'package:cake_wallet/generated/i18n.dart'; -import 'package:cake_wallet/src/screens/exchange/exchange_page.dart'; -import 'package:cake_wallet/src/screens/exchange/widgets/present_provider_picker.dart'; -import 'package:cw_core/crypto_currency.dart'; -import 'package:flutter/foundation.dart'; -import 'package:flutter_test/flutter_test.dart'; - -import '../components/common_test_cases.dart'; -import '../components/common_test_constants.dart'; -import 'auth_page_robot.dart'; - -class ExchangePageRobot { - ExchangePageRobot(this.tester) - : commonTestCases = CommonTestCases(tester), - authPageRobot = AuthPageRobot(tester); - - WidgetTester tester; - AuthPageRobot authPageRobot; - CommonTestCases commonTestCases; - - Future isExchangePage() async { - await commonTestCases.isSpecificPage(); - await commonTestCases.takeScreenshots('exchange_page'); - await commonTestCases.defaultSleepTime(); - } - - void hasResetButton() { - commonTestCases.hasText(S.current.reset); - } - - void displaysPresentProviderPicker() { - commonTestCases.hasType(); - } - - Future displayBothExchangeCards() async { - final ExchangePage exchangeCard = tester.widget( - find.byType(ExchangePage), - ); - - final depositKey = exchangeCard.depositKey; - final receiveKey = exchangeCard.receiveKey; - - final depositExchangeCard = find.byKey(depositKey); - expect(depositExchangeCard, findsOneWidget); - - final receiveExchangeCard = find.byKey(receiveKey); - expect(receiveExchangeCard, findsOneWidget); - } - - void confirmRightComponentsDisplayOnDepositExchangeCards() { - ExchangePage exchangePage = tester.widget(find.byType(ExchangePage)); - final exchangeViewModel = exchangePage.exchangeViewModel; - final depositCardPrefix = 'deposit_exchange_card'; - - commonTestCases.hasValueKey('${depositCardPrefix}_title_key'); - commonTestCases.hasValueKey('${depositCardPrefix}_currency_picker_button_key'); - commonTestCases.hasValueKey('${depositCardPrefix}_selected_currency_text_key'); - commonTestCases.hasValueKey('${depositCardPrefix}_amount_textfield_key'); - - exchangePage.depositKey.currentState!.changeLimits(min: '0.1'); - - commonTestCases.hasValueKey('${depositCardPrefix}_min_limit_text_key'); - - final initialCurrency = exchangeViewModel.depositCurrency; - if (initialCurrency.tag != null) { - commonTestCases.hasValueKey('${depositCardPrefix}_selected_currency_tag_text_key'); - } - - if (exchangeViewModel.hasAllAmount) { - commonTestCases.hasValueKey('${depositCardPrefix}_send_all_button_key'); - } - - if (exchangeViewModel.isMoneroWallet) { - commonTestCases.hasValueKey('${depositCardPrefix}_address_book_button_key'); - } - - if (exchangeViewModel.isDepositAddressEnabled) { - commonTestCases.hasValueKey('${depositCardPrefix}_editable_address_textfield_key'); - } else { - commonTestCases.hasValueKey('${depositCardPrefix}_non_editable_address_textfield_key'); - commonTestCases.hasValueKey('${depositCardPrefix}_copy_refund_address_button_key'); - } - - // commonTestCases.hasValueKey('${depositCardPrefix}_max_limit_text_key'); - } - - void confirmRightComponentsDisplayOnReceiveExchangeCards() { - ExchangePage exchangePage = tester.widget(find.byType(ExchangePage)); - final exchangeViewModel = exchangePage.exchangeViewModel; - final receiveCardPrefix = 'receive_exchange_card'; - - commonTestCases.hasValueKey('${receiveCardPrefix}_title_key'); - commonTestCases.hasValueKey('${receiveCardPrefix}_currency_picker_button_key'); - commonTestCases.hasValueKey('${receiveCardPrefix}_selected_currency_text_key'); - commonTestCases.hasValueKey('${receiveCardPrefix}_amount_textfield_key'); - commonTestCases.hasValueKey('${receiveCardPrefix}_min_limit_text_key'); - - final initialCurrency = exchangeViewModel.receiveCurrency; - if (initialCurrency.tag != null) { - commonTestCases.hasValueKey('${receiveCardPrefix}_selected_currency_tag_text_key'); - } - - if (exchangeViewModel.hasAllAmount) { - commonTestCases.hasValueKey('${receiveCardPrefix}_send_all_button_key'); - } - - if (exchangeViewModel.isMoneroWallet) { - commonTestCases.hasValueKey('${receiveCardPrefix}_address_book_button_key'); - } - - commonTestCases.hasValueKey('${receiveCardPrefix}_editable_address_textfield_key'); - } - - Future selectDepositCurrency(CryptoCurrency depositCurrency) async { - final depositPrefix = 'deposit_exchange_card'; - final currencyPickerKey = '${depositPrefix}_currency_picker_button_key'; - final currencyPickerDialogKey = '${depositPrefix}_currency_picker_dialog_button_key'; - - await commonTestCases.tapItemByKey(currencyPickerKey); - commonTestCases.hasValueKey(currencyPickerDialogKey); - - ExchangePage exchangePage = tester.widget(find.byType(ExchangePage)); - final exchangeViewModel = exchangePage.exchangeViewModel; - - if (depositCurrency == exchangeViewModel.depositCurrency) { - await commonTestCases.defaultSleepTime(); - await commonTestCases - .tapItemByKey('picker_items_index_${depositCurrency.name}_selected_item_button_key'); - return; - } - - await commonTestCases.enterText(depositCurrency.name, 'search_bar_widget_key'); - - await commonTestCases.defaultSleepTime(); - - await commonTestCases.tapItemByKey('picker_items_index_${depositCurrency.name}_button_key'); - } - - Future selectReceiveCurrency(CryptoCurrency receiveCurrency) async { - final receivePrefix = 'receive_exchange_card'; - final currencyPickerKey = '${receivePrefix}_currency_picker_button_key'; - final currencyPickerDialogKey = '${receivePrefix}_currency_picker_dialog_button_key'; - - await commonTestCases.tapItemByKey(currencyPickerKey); - commonTestCases.hasValueKey(currencyPickerDialogKey); - - ExchangePage exchangePage = tester.widget(find.byType(ExchangePage)); - final exchangeViewModel = exchangePage.exchangeViewModel; - - if (receiveCurrency == exchangeViewModel.receiveCurrency) { - await commonTestCases - .tapItemByKey('picker_items_index_${receiveCurrency.name}_selected_item_button_key'); - return; - } - - await commonTestCases.enterText(receiveCurrency.name, 'search_bar_widget_key'); - - await commonTestCases.defaultSleepTime(); - - await commonTestCases.tapItemByKey('picker_items_index_${receiveCurrency.name}_button_key'); - } - - Future enterDepositAmount(String amount) async { - await commonTestCases.enterText(amount, 'deposit_exchange_card_amount_textfield_key'); - } - - Future enterDepositRefundAddress({String? depositAddress}) async { - ExchangePage exchangePage = tester.widget(find.byType(ExchangePage)); - final exchangeViewModel = exchangePage.exchangeViewModel; - - if (exchangeViewModel.isDepositAddressEnabled && depositAddress != null) { - await commonTestCases.enterText( - depositAddress, 'deposit_exchange_card_editable_address_textfield_key'); - } - } - - Future enterReceiveAddress(String receiveAddress) async { - await commonTestCases.enterText( - receiveAddress, - 'receive_exchange_card_editable_address_textfield_key', - ); - await commonTestCases.defaultSleepTime(); - } - - Future onExchangeButtonPressed() async { - await commonTestCases.tapItemByKey('exchange_page_exchange_button_key'); - await commonTestCases.defaultSleepTime(); - } - - bool hasMaxLimitError() { - final maxErrorText = find.text(S.current.error_text_input_above_maximum_limit); - - bool hasMaxError = maxErrorText.tryEvaluate(); - - return hasMaxError; - } - - bool hasMinLimitError() { - final minErrorText = find.text(S.current.error_text_input_below_minimum_limit); - - bool hasMinError = minErrorText.tryEvaluate(); - - return hasMinError; - } - - bool hasTradeCreationFailureError() { - final tradeCreationFailureDialogButton = - find.byKey(ValueKey('exchange_page_trade_creation_failure_dialog_button_key')); - - bool hasTradeCreationFailure = tradeCreationFailureDialogButton.tryEvaluate(); - tester.printToConsole('Trade not created error: $hasTradeCreationFailure'); - return hasTradeCreationFailure; - } - - Future onTradeCreationFailureDialogButtonPressed() async { - await commonTestCases.tapItemByKey('exchange_page_trade_creation_failure_dialog_button_key'); - } - - /// Handling Trade Failure Errors or errors shown through the Failure Dialog. - /// - /// Simulating the user's flow and response when this error comes up. - /// Examples are: - /// - No provider can handle this trade error, - /// - Trade amount below limit error. - Future _handleTradeCreationFailureErrors() async { - tester.printToConsole('Inside trade creation failure handle'); - bool isTradeCreationFailure = false; - - isTradeCreationFailure = hasTradeCreationFailureError(); - - int maxRetries = 3; - int retries = 0; - - while (isTradeCreationFailure && retries < maxRetries) { - await tester.pumpAndSettle(); - - await onTradeCreationFailureDialogButtonPressed(); - - await commonTestCases.defaultSleepTime(seconds: 5); - - await onExchangeButtonPressed(); - - await _handleAuthPage(); - - isTradeCreationFailure = hasTradeCreationFailureError(); - retries++; - } - } - - /// Handles the min limit error. - /// - /// Simulates the user's flow and response when it comes up. - /// - /// Has a max retry of 20 times. - Future _handleMinLimitError(String initialAmount) async { - double amount; - - amount = double.parse(initialAmount); - - ExchangePage exchangePage = tester.widget(find.byType(ExchangePage)); - final exchangeViewModel = exchangePage.exchangeViewModel; - - amount = (exchangeViewModel.limits.min ?? 0.0) + 1.0; - - await enterDepositAmount(amount.toString()); - - await tester.pumpAndSettle(); - - await onExchangeButtonPressed(); - - await tester.pumpAndSettle(); - } - - /// Handles the max limit error. - /// - /// Simulates the user's flow and response when it comes up. - /// - /// Has a max retry of 20 times. - Future _handleMaxLimitError(String initialAmount) async { - double amount; - - amount = double.parse(initialAmount); - - ExchangePage exchangePage = tester.widget(find.byType(ExchangePage)); - final exchangeViewModel = exchangePage.exchangeViewModel; - - amount = (exchangeViewModel.limits.max ?? 0.0) - 1.0; - - await enterDepositAmount(amount.toString()); - - await tester.pumpAndSettle(); - - await onExchangeButtonPressed(); - - await tester.pumpAndSettle(); - } - - Future handleErrors(String initialAmount) async { - await tester.pumpAndSettle(); - - bool isMinLimitError = hasMinLimitError(); - - if (isMinLimitError) { - tester.printToConsole('Has min limit error'); - await _handleMinLimitError(initialAmount); - } - - bool isMaxLimitError = hasMaxLimitError(); - - if (isMaxLimitError) { - await _handleMaxLimitError(initialAmount); - } - - tester.printToConsole('No limits error, proceeding with flow'); - - await _handleAuthPage(); - - await tester.pumpAndSettle(); - - await _handleTradeCreationFailureErrors(); - - await tester.pumpAndSettle(); - - await commonTestCases.defaultSleepTime(); - } - - Future _handleAuthPage() async { - final onAuthPage = authPageRobot.onAuthPage(); - if (onAuthPage) { - await authPageRobot.enterPinCode(CommonTestConstants.pin); - } - - final onAuthPageDesktop = authPageRobot.onAuthPageDesktop(); - if (onAuthPageDesktop) { - await authPageRobot.enterPassword(CommonTestConstants.pin.join("")); - } - } -} diff --git a/integration_test/robots/exchange_trade_external_send_page_robot.dart b/integration_test/robots/exchange_trade_external_send_page_robot.dart deleted file mode 100644 index 8ddfe4915..000000000 --- a/integration_test/robots/exchange_trade_external_send_page_robot.dart +++ /dev/null @@ -1,35 +0,0 @@ -import 'package:flutter_test/flutter_test.dart'; - -import 'package:cake_wallet/src/screens/exchange_trade/exchange_trade_external_send_page.dart'; - -import '../components/common_test_cases.dart'; - -class ExchangeTradeExternalSendPageRobot { - ExchangeTradeExternalSendPageRobot(this.tester) : commonTestCases = CommonTestCases(tester); - - final WidgetTester tester; - late CommonTestCases commonTestCases; - - Future isExchangeTradeExternalSendPage() async { - await commonTestCases.isSpecificPage(); - await commonTestCases.takeScreenshots('exchange_trade_external_send_page'); - } - - Future verifySendDetailsItemsDisplayProperly() async { - final widget = - tester.widget(find.byType(ExchangeTradeExternalSendPage)); - final exchangeTradeViewModel = widget.exchangeTradeViewModel; - final items = exchangeTradeViewModel.items.where((item) => item.isExternalSendDetail).toList(); - - for (var item in items) { - commonTestCases.hasValueKey('exchange_trade_external_send_page_send_item_${item.title}_key'); - tester.printToConsole('${item.title} present on screen'); - } - - commonTestCases.defaultSleepTime(); - } - - Future onContinueButtonPressed() async { - await commonTestCases.tapItemByKey('exchange_trade_external_send_page_continue_button_key'); - } -} diff --git a/integration_test/robots/exchange_trade_page_robot.dart b/integration_test/robots/exchange_trade_page_robot.dart deleted file mode 100644 index d8c5b2278..000000000 --- a/integration_test/robots/exchange_trade_page_robot.dart +++ /dev/null @@ -1,161 +0,0 @@ -import 'dart:async'; - -import 'package:cake_wallet/core/execution_state.dart'; -import 'package:cake_wallet/generated/i18n.dart'; -import 'package:cake_wallet/src/screens/exchange_trade/exchange_trade_page.dart'; -import 'package:flutter/foundation.dart'; -import 'package:flutter_test/flutter_test.dart'; - -import '../components/common_test_cases.dart'; - -class ExchangeTradePageRobot { - ExchangeTradePageRobot(this.tester) : commonTestCases = CommonTestCases(tester); - - final WidgetTester tester; - late CommonTestCases commonTestCases; - - Future isExchangeTradePage() async { - await commonTestCases.isSpecificPage(); - await commonTestCases.takeScreenshots('exchange_trade_page'); - } - - void hasInformationDialog() { - commonTestCases.hasValueKey('information_page_dialog_key'); - } - - Future onGotItButtonPressed() async { - await commonTestCases.tapItemByKey('information_page_got_it_button_key'); - await commonTestCases.defaultSleepTime(); - } - - Future onSendFromExternalButtonPressed() async { - tester.printToConsole('Routing to send from external details page'); - - await commonTestCases.tapItemByKey( - 'exchange_trade_page_send_from_external_button_key', - ); - } - - Future onSendFromCakeButtonPressed() async { - tester.printToConsole('Now sending from cake'); - - await commonTestCases.tapItemByKey( - 'exchange_trade_page_send_from_cake_button_key', - shouldPumpAndSettle: false, - ); - - final Completer completer = Completer(); - - // Loop to wait for the async operation to complete - while (true) { - await Future.delayed(Duration(seconds: 1)); - - final ExchangeTradeState state = tester.state(find.byType(ExchangeTradeForm)); - final execState = state.widget.exchangeTradeViewModel.sendViewModel.state; - - bool isDone = execState is ExecutedSuccessfullyState; - bool isFailed = execState is FailureState; - - tester.printToConsole('isDone: $isDone'); - tester.printToConsole('isFailed: $isFailed'); - - if (isDone || isFailed) { - tester.printToConsole( - isDone ? 'Completer is done' : 'Completer is done though operation failed'); - completer.complete(); - await tester.pump(); - break; - } else { - tester.printToConsole('Completer is not done'); - await tester.pump(); - } - } - - await expectLater(completer.future, completes); - - tester.printToConsole('Done confirming sending'); - - await commonTestCases.defaultSleepTime(seconds: 4); - } - - Future onSendButtonOnConfirmSendingDialogPressed() async { - tester.printToConsole('Send Button on Confirm Dialog Triggered'); - await commonTestCases.defaultSleepTime(seconds: 4); - - final sendText = find.text(S.current.send); - bool hasText = sendText.tryEvaluate(); - - if (hasText) { - await commonTestCases.tapItemByFinder(sendText); - - await commonTestCases.defaultSleepTime(seconds: 4); - } - } - - Future onCancelButtonOnConfirmSendingDialogPressed() async { - tester.printToConsole('Cancel Button on Confirm Dialog Triggered'); - - await commonTestCases.tapItemByKey( - 'exchange_trade_page_confirm_sending_dialog_cancel_button_key', - ); - - await commonTestCases.defaultSleepTime(); - } - - Future onSendFailureDialogButtonPressed() async { - await commonTestCases.defaultSleepTime(seconds: 6); - - tester.printToConsole('Send Button Failure Dialog Triggered'); - - await commonTestCases.tapItemByKey('exchange_trade_page_send_failure_dialog_button_key'); - } - - Future hasErrorWhileSending() async { - await tester.pump(); - - tester.printToConsole('Checking if there is an error'); - - final errorDialog = find.byKey( - ValueKey('exchange_trade_page_send_failure_dialog_button_key'), - ); - - bool hasError = errorDialog.tryEvaluate(); - - tester.printToConsole('Has error: $hasError'); - - return hasError; - } - - Future handleConfirmSendResult() async { - bool hasError = false; - - hasError = await hasErrorWhileSending(); - - int maxRetries = 20; - int retries = 0; - - while (hasError && retries < maxRetries) { - tester.printToConsole('hasErrorInLoop: $hasError'); - await tester.pump(); - - await onSendFailureDialogButtonPressed(); - tester.printToConsole('Failure button tapped'); - - await commonTestCases.defaultSleepTime(); - - await onSendFromCakeButtonPressed(); - tester.printToConsole('Confirm sending button tapped'); - - hasError = await hasErrorWhileSending(); - - retries++; - } - - if (!hasError) { - tester.printToConsole('No error, proceeding with flow'); - await tester.pump(); - } - - await commonTestCases.defaultSleepTime(); - } -} diff --git a/integration_test/robots/home_page_robot.dart b/integration_test/robots/home_page_robot.dart new file mode 100644 index 000000000..00d2155d3 --- /dev/null +++ b/integration_test/robots/home_page_robot.dart @@ -0,0 +1,227 @@ +import "package:cake_wallet/new-ui/pages/home_page.dart"; +import "package:cake_wallet/new-ui/widgets/coins_page/assets_history/history_tile.dart"; +import "package:cake_wallet/new-ui/widgets/coins_page/assets_history/transaction_details_modal.dart"; +import "package:cake_wallet/new-ui/widgets/coins_page/top_bar_widget/sync_bar.dart"; +import "package:cw_core/sync_status.dart"; +import "package:flutter/material.dart"; +import "package:flutter_test/flutter_test.dart"; + +import "../core/base_robot.dart"; + +class HomePageRobot extends BaseRobot { + HomePageRobot(super.tester); + + @override + Future isDisplayed() async { + final shown = await pumpUntil(() => isInFront); + + expect(shown, true, reason: "The home tab never came to the front"); + } + + bool get isInFront { + final stackFinder = find.byType(IndexedStack); + + if (!tester.any(stackFinder)) { + return false; + } + + final stack = tester.widget(stackFinder.first); + final index = stack.children.indexWhere((child) => child is NewHomePage); + + return index >= 0 && stack.index == index; + } + + Future hasWalletName(String name) async { + final nameFinder = find.byKey(const ValueKey("home_page_wallet_name_text_key")); + final syncBarFinder = find.byType(SyncBar); + + final shown = await pumpUntil(() => tester.any(nameFinder) || tester.any(syncBarFinder)); + + expect(shown, true, reason: "Top bar showed neither the wallet name nor the sync bar"); + + if (!tester.any(nameFinder)) { + tester.printToConsole("Sync bar is in place of the wallet name, not checking it on screen"); + return; + } + + expect(textByKey("home_page_wallet_name_text_key"), name); + } + + Future openSendSheet() async { + await tapByKey("home_page_send_button_key"); + } + + Future openReceiveSheet() async { + await tapByKey("home_page_receive_button_key"); + } + + Future openSwapSheet() async { + await tapByKey("home_page_swap_button_key"); + } + + Future openSettingsSheet() async { + await tapByKey("home_page_settings_button_key"); + } + + Future openHistoryTab() async { + final tabFinder = find.byKey(const ValueKey("line_tab_switcher_1_key")); + + if (!tester.any(tabFinder)) { + return; + } + + await tapWhenVisible(tabFinder); + + await settle(); + } + + Future confirmTransactionHistoryVisible({ + Duration timeout = const Duration(minutes: 3), + }) async { + final hasTransactions = await pumpUntil( + () => _dashboardTransactionCount() > 0, + timeout: timeout, + ); + + expect( + hasTransactions, + true, + reason: "No transactions arrived within ${timeout.inSeconds}s", + ); + + await openHistoryTab(); + + await pumpUntilFound(find.byType(HistoryTile)); + + expect( + tester.widgetList(find.byType(HistoryTile)).length, + lessThanOrEqualTo(3), + reason: "The home page preview should never render more than the short history", + ); + } + + Future openAllTransactions() async { + final actionButton = find.byKey(const ValueKey("assets_history_action_button_key")); + final historyBar = find.byKey(const ValueKey("history_top_bar_key")); + + await tapWhenVisible(tester.any(actionButton) ? actionButton : historyBar); + + await pumpUntilFound(find.byKey(const ValueKey("history_modal_key"))); + } + + Future confirmAllTransactionsVisible() async { + await pumpUntilFound(_allViewTiles); + + final rendered = tester.widgetList(_allViewTiles).length; + + if (_dashboardTransactionCount() > 3) { + expect( + rendered, + greaterThan(3), + reason: "The All view rendered $rendered tiles, no more than the home preview does", + ); + + return; + } + + expect(_allViewTiles, findsWidgets); + } + + String firstTransactionIdInAllView() { + final tile = tester.widgetList(_allViewTiles).first; + final key = tile.key! as ValueKey; + + return key.value.substring("home_page_transaction_".length, key.value.length - "_key".length); + } + + Future openFirstTransactionDetails() async { + await pumpUntilFound(_allViewTiles); + + await tester.tap(_allViewTiles.first); + + await pumpUntilFound(find.byType(TransactionDetailsModal)); + + await settle(); + } + + String openedTransactionId() { + final modal = tester.widget(find.byType(TransactionDetailsModal)); + + return modal.transactionDetailsViewModel.transactionInfo.id; + } + + void hasTransactionIdRow() { + expect( + find.byKey(const ValueKey("standard_list_item_transaction_details_id_key")), + findsOneWidget, + reason: "The details opened without showing the transaction id", + ); + } + + // The home preview stays mounted underneath the modal, so its tiles have to be left out. + Finder get _allViewTiles => find.descendant( + of: find.byKey(const ValueKey("history_modal_key")), + matching: find.byType(HistoryTile), + ); + + Future confirmSyncIndicatorShown({Duration timeout = const Duration(minutes: 3)}) async { + final syncBar = find.byType(SyncBar); + final tick = find.descendant(of: syncBar, matching: find.byIcon(Icons.check)); + + SyncStatus? lastStatus; + var sawIndicator = false; + + final synced = await pumpUntil( + () { + lastStatus = _dashboardStatus(); + + if (lastStatus == null) { + return false; + } + + final keyed = find.descendant( + of: syncBar, + matching: find.byKey(ValueKey(lastStatus.runtimeType)), + ); + + sawIndicator = sawIndicator || tester.any(keyed) || tester.any(tick); + + return lastStatus is SyncedSyncStatus; + }, + timeout: timeout, + ); + + expect( + synced, + true, + reason: "The wallet never synced within ${timeout.inMinutes}m, " + "it reported ${lastStatus.runtimeType}", + ); + + expect( + sawIndicator, + true, + reason: "The sync bar never showed a status while the wallet synced", + ); + } + + SyncStatus? _dashboardStatus() { + final finder = find.byType(NewHomePage); + + if (!tester.any(finder)) { + return null; + } + + return tester.widget(finder.first).dashboardViewModel.status; + } + + int _dashboardTransactionCount() { + final finder = find.byType(NewHomePage); + + if (!tester.any(finder)) { + return 0; + } + + return tester.widget(finder.first).dashboardViewModel.transactions.length; + } +} diff --git a/integration_test/robots/lightning_username_page_robot.dart b/integration_test/robots/lightning_username_page_robot.dart new file mode 100644 index 000000000..69ffc3d0e --- /dev/null +++ b/integration_test/robots/lightning_username_page_robot.dart @@ -0,0 +1,17 @@ +import "package:flutter/material.dart"; +import "package:flutter_test/flutter_test.dart"; + +import "../core/base_robot.dart"; + +class LightningUsernamePageRobot extends BaseRobot { + LightningUsernamePageRobot(super.tester); + + @override + Future isDisplayed() async { + await pumpUntilFound(find.byKey(const ValueKey("lightning_username_page_skip_button_key"))); + } + + Future tapSkip() async { + await tapByKey("lightning_username_page_skip_button_key"); + } +} diff --git a/integration_test/robots/manage_nodes_page_robot.dart b/integration_test/robots/manage_nodes_page_robot.dart new file mode 100644 index 000000000..cbc328229 --- /dev/null +++ b/integration_test/robots/manage_nodes_page_robot.dart @@ -0,0 +1,39 @@ +import "package:cake_wallet/generated/i18n.dart"; +import "package:cake_wallet/src/screens/nodes/widgets/node_list_row.dart"; +import "package:cake_wallet/src/screens/settings/manage_nodes_page.dart"; +import "package:flutter_test/flutter_test.dart"; + +import "../core/base_robot.dart"; + +class ManageNodesPageRobot extends BaseRobot { + ManageNodesPageRobot(super.tester); + + @override + Future isDisplayed() async { + await isSpecificPage(); + } + + String currentNodeUri() { + final page = tester.widget(find.byType(ManageNodesPage)); + + return page.nodeListViewModel.currentNode.uriRaw; + } + + Future switchToAnotherNode() async { + final rows = find.byType(NodeListRow); + + await pumpUntilFound(rows.first); + + expect( + tester.widgetList(rows).length, + greaterThan(1), + reason: "Only the current node is listed, there is nothing to switch to", + ); + + await tapWhenVisible(rows.at(1)); + + await tapWhenVisible(find.text(S.current.change)); + + await settle(); + } +} diff --git a/integration_test/robots/new_dashboard_robot.dart b/integration_test/robots/new_dashboard_robot.dart new file mode 100644 index 000000000..1aa332d17 --- /dev/null +++ b/integration_test/robots/new_dashboard_robot.dart @@ -0,0 +1,25 @@ +import "package:flutter/material.dart"; +import "package:flutter_test/flutter_test.dart"; + +import "../core/base_robot.dart"; + +class NewDashboardRobot extends BaseRobot { + NewDashboardRobot(super.tester); + + @override + Future isDisplayed({Duration timeout = const Duration(seconds: 30)}) async { + await pumpUntilFound(find.byKey(const ValueKey("new_dashboard_page_key")), timeout: timeout); + } + + Future openHomeTab() async { + await tapByKey("dashboard_page_home_action_button_key"); + } + + Future openWalletsTab() async { + await tapByKey("dashboard_page_wallets_action_button_key"); + } + + Future openContactsTab() async { + await tapByKey("dashboard_page_contacts_action_button_key"); + } +} diff --git a/integration_test/robots/new_receive_page_robot.dart b/integration_test/robots/new_receive_page_robot.dart new file mode 100644 index 000000000..542acceb0 --- /dev/null +++ b/integration_test/robots/new_receive_page_robot.dart @@ -0,0 +1,57 @@ +import "package:cake_wallet/new-ui/pages/receive_page.dart"; +import "package:flutter/material.dart"; +import "package:flutter_test/flutter_test.dart"; + +import "../core/base_robot.dart"; + +class NewReceivePageRobot extends BaseRobot { + NewReceivePageRobot(super.tester); + + @override + Future isDisplayed() async { + await pumpUntilFound(find.byType(NewReceivePage)); + } + + Future confirmAddressMatches(String expected) async { + final wanted = _normalise(expected); + + await pumpUntilFound(find.byKey(const ValueKey("receive_page_address_key"))); + + final matched = await pumpUntil(() => _renderedAddress() == wanted); + + expect( + matched, + true, + reason: "Receive page should show $wanted but showed ${_renderedAddress()}", + ); + } + + String _normalise(String address) => + address.replaceAll("bitcoincash:", "").replaceAll(RegExp(r"\s"), ""); + + String? _renderedAddress() { + final root = find.byKey(const ValueKey("receive_page_address_key")); + + if (!tester.any(root)) { + return null; + } + + final rich = find.descendant(of: root, matching: find.byType(RichText)); + + if (tester.any(rich)) { + final text = _normalise(tester.widget(rich.first).text.toPlainText()); + + if (text.isNotEmpty) { + return text; + } + } + + final plain = find.descendant(of: root, matching: find.byType(Text)); + + if (tester.any(plain)) { + return _normalise(tester.widget(plain.first).data ?? ""); + } + + return null; + } +} diff --git a/integration_test/robots/new_send_page_robot.dart b/integration_test/robots/new_send_page_robot.dart new file mode 100644 index 000000000..3fef1a057 --- /dev/null +++ b/integration_test/robots/new_send_page_robot.dart @@ -0,0 +1,205 @@ +import "package:cake_wallet/core/execution_state.dart"; +import "package:cake_wallet/new-ui/pages/send_page.dart"; +import "package:cake_wallet/new-ui/widgets/confirm_swiper.dart"; +import "package:cake_wallet/new-ui/widgets/new_primary_button.dart"; +import "package:cake_wallet/new-ui/widgets/send_page/send_confirm_bottom_widget.dart"; +import "package:cake_wallet/new-ui/widgets/send_page/send_confirm_sheet.dart"; +import "package:cake_wallet/view_model/send/send_view_model.dart"; +import "package:flutter/material.dart"; +import "package:flutter_test/flutter_test.dart"; + +import "../core/base_robot.dart"; +import "../core/test_config.dart"; + +class NewSendPageRobot extends BaseRobot { + NewSendPageRobot(super.tester); + + @override + Future isDisplayed() async { + await pumpUntilFound(find.byType(NewSendPage)); + } + + Future enterAddress(String address) async { + await _enterTextInInput("send_page_address_input_key", address); + } + + Future enterAmount(String amount) async { + await _enterTextInInput("send_page_amount_input_key", amount); + } + + String enteredAddress() => _textInInput("send_page_address_input_key"); + + Future pickContactFromAddressBook(String contactName) async { + await tapByKey("send_page_address_book_button_key"); + + final tabs = find.byType(Tab); + + await pumpUntilFound(tabs); + + await tester.tap(tabs.at(1)); + + await settle(); + + final contactFinder = find.byKey(ValueKey(contactName)); + + await pumpUntilFound(contactFinder); + + await tester.tap(contactFinder.first); + + await pumpUntilGone(contactFinder); + } + + Future tapSendButton() async { + await tapByKey("send_page_send_button_key"); + } + + Future tapSendButtonWhenReady({Duration timeout = TestConfig.walletSyncBudget}) async { + final ready = await pumpUntil(() => _model()?.isReadyForSend ?? false, timeout: timeout); + + expect( + ready, + true, + reason: "The wallet never became ready to send within ${timeout.inMinutes}m, " + "so the send button stayed disabled", + ); + + await tapSendButton(); + } + + bool get isSendFlowOpen => + tester.any(find.byType(NewSendPage)) || tester.any(find.byType(SendConfirmSheet)); + + Future swipeToConfirm({Duration timeout = const Duration(seconds: 90)}) async { + final finder = find.byKey(const ValueKey("send_page_confirm_swiper_key")); + + await pumpUntilFound(finder, timeout: timeout); + + await settle(max: const Duration(seconds: 2)); + + final width = tester.view.physicalSize.width / tester.view.devicePixelRatio; + await tester.drag(finder.first, Offset(width, 0)); + + await tester.pump(const Duration(milliseconds: 500)); + } + + Future confirmSendButtonReplacesSwiper() async { + final swiper = find.byKey(const ValueKey("send_page_confirm_swiper_key")); + + await pumpUntilFound(swiper); + + tester.binding.platformDispatcher.accessibilityFeaturesTestValue = + const FakeAccessibilityFeatures(accessibleNavigation: true); + addTearDown(tester.binding.platformDispatcher.clearAccessibilityFeaturesTestValue); + + await settle(max: const Duration(seconds: 2)); + + final button = find.descendant(of: swiper, matching: find.byType(NewPrimaryButton)); + + expect( + tester.any(button), + true, + reason: "Accessible navigation is on but the sheet still only offers the swiper", + ); + + expect( + tester.widget(button.first).onPressed, + isNotNull, + reason: "The send button a screen reader gets is disabled", + ); + } + + Future confirmTransactionBuilt({Duration timeout = const Duration(minutes: 3)}) async { + await pumpUntil( + () => tester.any(find.byType(ConfirmSwiper)) || _model()?.state is FailureState, + timeout: timeout, + ); + + if (tester.any(find.byType(ConfirmSwiper))) { + return; + } + + fail("No confirm sheet after ${timeout.inSeconds}s. ${_describeSendState()}"); + } + + Future confirmTransactionCommitted({ + Duration timeout = const Duration(minutes: 3), + }) async { + await pumpUntilFound(find.byType(TransactionCommittedScreenActionButton), timeout: timeout); + } + + Future expectNoTransactionBuilt({ + Duration window = const Duration(seconds: 15), + }) async { + final offered = await pumpUntil( + () => tester.any(find.byType(ConfirmSwiper)), + timeout: window, + ); + + expect( + offered, + false, + reason: + "The send screen showed the swiper, wanting to broadcast a transaction it should have refused", + ); + } + + SendViewModel? _model() { + final sheet = find.byType(SendConfirmSheet); + + if (tester.any(sheet)) { + return tester.widget(sheet.first).sendViewModel; + } + + final page = find.byType(NewSendPage); + + if (tester.any(page)) { + return tester.widget(page.first).sendViewModel; + } + + return null; + } + + String _describeSendState() { + final model = _model(); + + final available = model?.wallet.balance.values + .map((balance) => balance.available) + .where((amount) => amount.sign > 0) + .join(", "); + + final errorFinder = find.byType(TransactionErrorActions); + final error = tester.any(errorFinder) + ? tester.widget(errorFinder.first).errorText + : model?.state is FailureState + ? (model!.state as FailureState).error + : null; + + return "state=${model?.state.runtimeType}, " + "wallet holds ${available == null || available.isEmpty ? "nothing" : available}" + "${error == null ? "" : ", the chain said: $error"}"; + } + + String _textInInput(String inputKey) { + final inputFinder = find.byKey(ValueKey(inputKey)); + + if (!tester.any(inputFinder)) { + return "unknown"; + } + + final field = find.descendant(of: inputFinder, matching: find.byType(EditableText)); + + return tester.widget(field.first).controller.text; + } + + Future _enterTextInInput(String inputKey, String text) async { + final inputFinder = find.byKey(ValueKey(inputKey)); + + await pumpUntilFound(inputFinder); + + final fieldFinder = find.descendant(of: inputFinder, matching: find.byType(EditableText)); + + await tester.enterText(fieldFinder.first, text); + + await tester.pump(const Duration(milliseconds: 300)); + } +} diff --git a/integration_test/robots/new_settings_page_robot.dart b/integration_test/robots/new_settings_page_robot.dart new file mode 100644 index 000000000..8b604db77 --- /dev/null +++ b/integration_test/robots/new_settings_page_robot.dart @@ -0,0 +1,45 @@ +import "package:cake_wallet/new-ui/pages/settings_page.dart"; +import "package:flutter/material.dart"; +import "package:flutter_test/flutter_test.dart"; + +import "../core/base_robot.dart"; + +class NewSettingsPageRobot extends BaseRobot { + NewSettingsPageRobot(super.tester); + + @override + Future isDisplayed() async { + await pumpUntilFound(find.byType(SettingsMainPage)); + } + + Future openRow(String route) async { + await scrollUntilRowVisible(route); + await tapByKey(route); + } + + Future scrollUntilRowVisible(String route) async { + final rowFinder = find.byKey(ValueKey(route)); + + if (tester.any(rowFinder)) { + return; + } + + final scrollableFinder = find.descendant( + of: find.byType(SettingsMainPage), + matching: find.byType(Scrollable), + ); + + await tester.scrollUntilVisible( + rowFinder, + 200, + scrollable: scrollableFinder.first, + maxScrolls: 30, + ); + + await tester.pump(const Duration(milliseconds: 300)); + } + + Future confirmLeafPageDisplayed(Type pageType) async { + await pumpUntilFound(find.byType(pageType)); + } +} diff --git a/integration_test/robots/new_swap_page_robot.dart b/integration_test/robots/new_swap_page_robot.dart new file mode 100644 index 000000000..bc58812d2 --- /dev/null +++ b/integration_test/robots/new_swap_page_robot.dart @@ -0,0 +1,211 @@ +import "package:cake_wallet/core/execution_state.dart"; +import "package:cake_wallet/exchange/limits_state.dart"; +import "package:cake_wallet/new-ui/pages/swap_page.dart"; +import "package:cake_wallet/new-ui/widgets/currency_picker/currency_picker_row.dart"; +import "package:cake_wallet/new-ui/widgets/currency_picker/currency_picker_search_field.dart"; +import "package:cake_wallet/new-ui/widgets/swap_page/swap_amount_box.dart"; +import "package:cake_wallet/new-ui/widgets/swap_page/swap_confirm_sheet.dart"; +import "package:cake_wallet/src/widgets/primary_button.dart"; +import "package:cake_wallet/view_model/send/send_view_model_state.dart"; +import "package:cw_core/crypto_currency.dart"; +import "package:cw_core/wallet_type.dart"; +import "package:flutter/material.dart"; +import "package:flutter_test/flutter_test.dart"; + +import "../core/base_robot.dart"; +import "../core/test_config.dart"; +import "../core/test_wallets.dart"; + +class NewSwapPageRobot extends BaseRobot { + NewSwapPageRobot(super.tester); + + @override + Future isDisplayed() async { + await pumpUntilFound(find.byType(NewSwapPage)); + } + + Future enterDepositAmount(String amount) async { + await enterTextByKey("swap_page_deposit_amount_field_key", amount); + } + + Future chooseReceiveCurrency(CryptoCurrency currency) async { + await tapByKey("swap_amount_box_receive_currency_button_key"); + + final search = find.descendant( + of: find.byType(CurrencyPickerSearchField), + matching: find.byType(EditableText), + ); + + await pumpUntilFound(search); + + await tester.enterText(search.first, currency.title); + await settle(max: const Duration(seconds: 2)); + + await tapWhenVisible( + find.byWidgetPredicate( + (widget) => widget is CurrencyPickerRow && _isSameCurrency(widget.currency, currency), + ), + ); + + final chosen = await pumpUntil( + () => _isSameCurrency( + tester.widget(find.byType(NewSwapPage)).exchangeViewModel.receiveCurrency, + currency, + ), + ); + + expect( + chosen, + true, + reason: "The picker did not switch the receive currency to ${currency.title}", + ); + } + + static bool _isSameCurrency(CryptoCurrency one, CryptoCurrency other) => + one.title.toLowerCase() == other.title.toLowerCase() && + (one.tag ?? "").toLowerCase() == (other.tag ?? "").toLowerCase(); + + Future confirmQuoteReceived({Duration timeout = const Duration(seconds: 90)}) async { + final received = await pumpUntil(() => _bestRate() > 0, timeout: timeout); + + expect(received, true, reason: "No provider quote arrived within ${timeout.inSeconds}s"); + } + + Future enterMinimumViableDepositAmount() async { + final limitsLoaded = await pumpUntil( + () => _limitsState() is LimitsLoadedSuccessfully, + timeout: const Duration(seconds: 90), + ); + + expect(limitsLoaded, true, reason: "Provider limits never loaded"); + + final min = _limitsMin() ?? 0; + + // 5 percent over the minimum, so a small rate move does not invalidate the trade. + final amount = min > 0 ? (min * 1.05).toStringAsFixed(8) : "0.01"; + await enterDepositAmount(amount); + } + + Future enterTestReceiveAddress() async { + final exchangeViewModel = + tester.widget(find.byType(NewSwapPage)).exchangeViewModel; + final receiveType = cryptoCurrencyOrTokenToWalletType(exchangeViewModel.receiveCurrency); + final address = receiveType == null ? "" : TestWallets.receiveAddressFor(receiveType); + + expect( + address.isNotEmpty, + true, + reason: "No test receive address for ${exchangeViewModel.receiveCurrency.title}", + ); + + final receiveCard = find.byWidgetPredicate( + (widget) => widget is SwapAmountBox && widget.isReceiverCard, + ); + + tester.state(receiveCard).addressController.text = address; + await settle(); + + return address; + } + + Future confirmSwapButtonEnabled({Duration timeout = TestConfig.walletSyncBudget}) async { + final button = find.byKey(const ValueKey("exchange_page_exchange_button_key")); + + final enabled = await pumpUntil( + () => tester.any(button) && !tester.widget(button).isDisabled, + timeout: timeout, + ); + + expect( + enabled, + true, + reason: "The swap button stayed disabled with an amount, a quote and a receive address", + ); + } + + Future tapSwapButton() async { + await tapByKey("exchange_page_exchange_button_key"); + } + + Future confirmTradeCreated({Duration timeout = const Duration(minutes: 2)}) async { + await pumpUntilFound(find.byType(SwapConfirmSheet), timeout: timeout); + } + + void confirmPayoutAddress(String expected) { + final sheet = tester.widget(find.byType(SwapConfirmSheet)); + final payout = sheet.exchangeTradeViewModel.trade.payoutAddress ?? ""; + final isHex = expected.startsWith("0x"); + + expect( + isHex ? payout.toLowerCase() : payout, + isHex ? expected.toLowerCase() : expected, + reason: "The trade pays out somewhere other than the test wallet", + ); + } + + Future confirmDepositCommitted({Duration timeout = const Duration(minutes: 3)}) async { + ExecutionState? lastState; + + final committed = await pumpUntil( + () { + final sheet = find.byType(SwapConfirmSheet); + + if (!tester.any(sheet)) { + return true; + } + + lastState = + tester.widget(sheet).exchangeTradeViewModel.sendViewModel.state; + + return lastState is TransactionCommitted; + }, + timeout: timeout, + ); + + expect( + committed, + true, + reason: "The deposit never committed within ${timeout.inMinutes}m, " + "the sheet reported ${lastState.runtimeType}", + ); + + expect( + lastState, + isA(), + reason: "The confirm sheet closed before the deposit committed, " + "it last reported ${lastState.runtimeType}", + ); + + await pumpUntilGone(find.byType(NewSwapPage)); + } + + double? _limitsMin() { + final finder = find.byType(NewSwapPage); + + if (!tester.any(finder)) { + return null; + } + + return tester.widget(finder.first).exchangeViewModel.limits.min; + } + + LimitsState? _limitsState() { + final finder = find.byType(NewSwapPage); + + if (!tester.any(finder)) { + return null; + } + + return tester.widget(finder.first).exchangeViewModel.limitsState; + } + + double _bestRate() { + final finder = find.byType(NewSwapPage); + + if (!tester.any(finder)) { + return 0; + } + + return tester.widget(finder.first).exchangeViewModel.bestRate; + } +} diff --git a/integration_test/robots/new_wallet_page_robot.dart b/integration_test/robots/new_wallet_page_robot.dart index dda468398..f39bec017 100644 --- a/integration_test/robots/new_wallet_page_robot.dart +++ b/integration_test/robots/new_wallet_page_robot.dart @@ -1,36 +1,32 @@ -import 'package:cake_wallet/src/screens/new_wallet/new_wallet_page.dart'; -import 'package:flutter_test/flutter_test.dart'; +import "package:cake_wallet/src/screens/new_wallet/new_wallet_page.dart"; +import "package:cake_wallet/src/widgets/alert_with_one_action.dart"; +import "package:flutter_test/flutter_test.dart"; -import '../components/common_test_cases.dart'; +import "../core/base_robot.dart"; -class NewWalletPageRobot { - NewWalletPageRobot(this.tester) : commonTestCases = CommonTestCases(tester); +class NewWalletPageRobot extends BaseRobot { + NewWalletPageRobot(super.tester); - final WidgetTester tester; - late CommonTestCases commonTestCases; - - Future isNewWalletPage() async { - await commonTestCases.isSpecificPage(); - await commonTestCases.takeScreenshots('new_wallet_page'); + @override + Future isDisplayed() async { + await isSpecificPage(); } Future enterWalletName(String walletName) async { - await commonTestCases.enterText( - walletName, - 'new_wallet_page_wallet_name_textformfield_key', - ); - await commonTestCases.defaultSleepTime(); + await enterTextByKey("new_wallet_page_wallet_name_textformfield_key", walletName); } Future generateWalletName() async { - await commonTestCases.tapItemByKey( - 'new_wallet_page_wallet_name_textformfield_generate_name_button_key', - ); - await commonTestCases.defaultSleepTime(); + await tapByKey("new_wallet_page_wallet_name_textformfield_generate_name_button_key"); + } + + Future expectNameRejected() async { + await pumpUntilFound(find.byType(AlertWithOneAction)); + + hasType(); } Future onNextButtonPressed() async { - await commonTestCases.tapItemByKey('new_wallet_page_confirm_button_key'); - await commonTestCases.defaultSleepTime(); + await tapByKey("new_wallet_page_confirm_button_key"); } } diff --git a/integration_test/robots/new_wallet_type_page_robot.dart b/integration_test/robots/new_wallet_type_page_robot.dart index 11785b67d..0fbb7f4f4 100644 --- a/integration_test/robots/new_wallet_type_page_robot.dart +++ b/integration_test/robots/new_wallet_type_page_robot.dart @@ -1,55 +1,29 @@ -import 'package:cake_wallet/generated/i18n.dart'; -import 'package:cake_wallet/src/screens/new_wallet/new_wallet_type_page.dart'; -import 'package:cake_wallet/themes/core/material_base_theme.dart'; -import 'package:cw_core/wallet_type.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; +import "package:cake_wallet/src/screens/new_wallet/new_wallet_type_page.dart"; +import "package:cw_core/wallet_type.dart"; +import "package:flutter/material.dart"; +import "package:flutter_test/flutter_test.dart"; -import '../components/common_test_cases.dart'; +import "../core/base_robot.dart"; -class NewWalletTypePageRobot { - NewWalletTypePageRobot(this.tester) : commonTestCases = CommonTestCases(tester); +class NewWalletTypePageRobot extends BaseRobot { + NewWalletTypePageRobot(super.tester); - final WidgetTester tester; - late CommonTestCases commonTestCases; - - Future isNewWalletTypePage() async { - await commonTestCases.isSpecificPage(); - await commonTestCases.takeScreenshots('new_wallet_type_page'); - } - - void displaysCorrectTitle(bool isCreate) { - commonTestCases.hasText( - isCreate ? S.current.wallet_list_create_new_wallet : S.current.wallet_list_restore_wallet, - ); - } - - void hasWalletTypeForm() { - commonTestCases.hasType(); - } - - void displaysCorrectImage(ThemeType type) { - final walletTypeImage = Image.asset('assets/images/wallet_type.png').image; - final walletTypeLightImage = Image.asset('assets/images/wallet_type_light.png').image; - - find.image( - type == ThemeType.dark ? walletTypeImage : walletTypeLightImage, - ); + @override + Future isDisplayed() async { + await isSpecificPage(); } Future findParticularWalletTypeInScrollableList(WalletType type) async { - await tester.pump(Duration(milliseconds: 1000)); - final scrollableWidget = find.descendant( - of: find.byKey(Key('new_wallet_type_scrollable_key')), + of: find.byKey(const Key("new_wallet_type_scrollable_key")), matching: find.byType(Scrollable), ); - final targetWidget = find.byKey(ValueKey('new_wallet_type_${type.name}_button_key')); + final targetWidget = find.byKey(ValueKey("new_wallet_type_${type.name}_button_key")); - tester.printToConsole('Attempting to scroll to wallet type ${type.name}'); + tester.printToConsole("Attempting to scroll to wallet type ${type.name}"); - await tester.pump(Duration(milliseconds: 500)); + await pumpUntilFound(scrollableWidget.first); await tester.scrollUntilVisible( targetWidget, @@ -58,12 +32,14 @@ class NewWalletTypePageRobot { maxScrolls: 20, ); - await tester.pumpAndSettle(Duration(milliseconds: 1000)); + await settle(); - expect(tester.any(targetWidget), true, - reason: 'Wallet type ${type.name} should be visible after scrolling'); + expect( + tester.any(targetWidget), + true, + reason: "Wallet type ${type.name} should be visible after scrolling", + ); - // Additional check to ensure the widget is actually on screen final widgetRect = tester.getRect(targetWidget); final screenSize = tester.view.physicalSize; final screenRect = Rect.fromLTWH(0, 0, screenSize.width, screenSize.height); @@ -71,15 +47,13 @@ class NewWalletTypePageRobot { expect( screenRect.overlaps(widgetRect), true, - reason: 'Wallet type ${type.name} should be within screen bounds', + reason: "Wallet type ${type.name} should be within screen bounds", ); - tester.printToConsole('Wallet type ${type.name} is now visible and tappable'); + tester.printToConsole("Wallet type ${type.name} is now visible and tappable"); } - // Tapping a wallet type now navigates directly (the page no longer has a - // separate "Next" button — selection happens on tap). Future selectWalletType(WalletType type) async { - await commonTestCases.tapItemByKey('new_wallet_type_${type.name}_button_key'); + await tapByKey("new_wallet_type_${type.name}_button_key"); } } diff --git a/integration_test/robots/pin_code_widget_robot.dart b/integration_test/robots/pin_code_widget_robot.dart index 35b9e200e..72a9d1d09 100644 --- a/integration_test/robots/pin_code_widget_robot.dart +++ b/integration_test/robots/pin_code_widget_robot.dart @@ -1,52 +1,24 @@ -import 'package:cake_wallet/src/screens/pin_code/pin_code_widget.dart'; -import 'package:flutter_test/flutter_test.dart'; +import "package:cake_wallet/src/screens/pin_code/pin_code_widget.dart"; +import "package:flutter_test/flutter_test.dart"; -import '../components/common_test_cases.dart'; +import "../core/base_robot.dart"; -class PinCodeWidgetRobot { - PinCodeWidgetRobot(this.tester) : commonTestCases = CommonTestCases(tester); +class PinCodeWidgetRobot extends BaseRobot { + PinCodeWidgetRobot(super.tester); - final WidgetTester tester; - late CommonTestCases commonTestCases; + @override + Future isDisplayed() async => hasPinCodeWidget(); void hasPinCodeWidget() { final pinCodeWidget = find.bySubtype(); expect(pinCodeWidget, findsOneWidget); } - void hasNumberButtonsVisible() { - // Confirmation for buttons 1-9 - for (var i = 1; i < 10; i++) { - commonTestCases.hasValueKey('pin_code_button_${i}_key'); + Future enterPinCode(List pinCode) async { + for (final pin in pinCode) { + await tapByKey("pin_code_button_${pin}_key"); } - // Confirmation for 0 button - commonTestCases.hasValueKey('pin_code_button_0_key'); - } - - Future enterPassword(String password, {int pumpDuration = 100}) async { - await commonTestCases.enterText( - password, - 'enter_wallet_password', - ); - await tester.pumpAndSettle(); - await commonTestCases.tapItemByKey( - 'unlock', - ); - await tester.pumpAndSettle(); - - await commonTestCases.defaultSleepTime(); - } - - Future enterPinCode(List pinCode, {int pumpDuration = 100}) async { - for (int pin in pinCode) { - await commonTestCases.tapItemByKey( - 'pin_code_button_${pin}_key', - pumpDuration: pumpDuration, - ); - } - - await commonTestCases.takeScreenshots('pin_code_widget'); - await commonTestCases.defaultSleepTime(); + await settle(); } } diff --git a/integration_test/robots/pre_seed_page_robot.dart b/integration_test/robots/pre_seed_page_robot.dart index 0e0eab4a1..7227d3bfc 100644 --- a/integration_test/robots/pre_seed_page_robot.dart +++ b/integration_test/robots/pre_seed_page_robot.dart @@ -1,24 +1,20 @@ import "package:cake_wallet/new-ui/pages/seed/pre_seed_page.dart"; -import 'package:flutter_test/flutter_test.dart'; -import '../components/common_test_cases.dart'; +import "../core/base_robot.dart"; -class PreSeedPageRobot { - PreSeedPageRobot(this.tester) : commonTestCases = CommonTestCases(tester); +class PreSeedPageRobot extends BaseRobot { + PreSeedPageRobot(super.tester); - final WidgetTester tester; - late CommonTestCases commonTestCases; - - Future isPreSeedPage() async { - await commonTestCases.isSpecificPage(); - await commonTestCases.takeScreenshots('pre_seed_page'); + @override + Future isDisplayed() async { + await isSpecificPage(); } + // The button is only built once all three boxes are ticked Future onConfirmButtonPressed() async { - await commonTestCases.tapItemByKey("pre_seed_page_only_way_checkbox_key"); - await commonTestCases.tapItemByKey("pre_seed_page_write_down_checkbox_key"); - await commonTestCases.tapItemByKey("pre_seed_page_never_share_checkbox_key"); - await commonTestCases.tapItemByKey('pre_seed_page_button_key'); - await commonTestCases.defaultSleepTime(); + await tapByKey("pre_seed_page_only_way_checkbox_key"); + await tapByKey("pre_seed_page_write_down_checkbox_key"); + await tapByKey("pre_seed_page_never_share_checkbox_key"); + await tapByKey("pre_seed_page_button_key"); } } diff --git a/integration_test/robots/restore_from_seed_or_key_robot.dart b/integration_test/robots/restore_from_seed_or_key_robot.dart index 5b35329e1..0ade26c64 100644 --- a/integration_test/robots/restore_from_seed_or_key_robot.dart +++ b/integration_test/robots/restore_from_seed_or_key_robot.dart @@ -1,123 +1,101 @@ -import 'package:cake_wallet/entities/seed_type.dart'; -import 'package:cake_wallet/generated/i18n.dart'; -import 'package:cake_wallet/src/screens/restore/wallet_restore_page.dart'; -import 'package:cake_wallet/src/widgets/validable_annotated_editable_text.dart'; -import 'package:flutter_test/flutter_test.dart'; +import "package:cake_wallet/entities/seed_type.dart"; +import "package:cake_wallet/src/screens/restore/wallet_restore_page.dart"; +import "package:cake_wallet/src/widgets/primary_button.dart"; +import "package:cake_wallet/src/widgets/validable_annotated_editable_text.dart"; +import "package:flutter/material.dart"; +import "package:flutter_test/flutter_test.dart"; -import '../components/common_test_cases.dart'; +import "../core/base_robot.dart"; -class RestoreFromSeedOrKeysPageRobot { - RestoreFromSeedOrKeysPageRobot(this.tester) : commonTestCases = CommonTestCases(tester); +class RestoreFromSeedOrKeysPageRobot extends BaseRobot { + RestoreFromSeedOrKeysPageRobot(super.tester); - final WidgetTester tester; - late CommonTestCases commonTestCases; - - Future isRestoreFromSeedKeyPage() async { - await commonTestCases.isSpecificPage(); - await commonTestCases.takeScreenshots('wallet_restore_page'); - } - - Future confirmViewComponentsDisplayProperlyPerPageView() async { - commonTestCases.hasText(S.current.wallet_name); - commonTestCases.hasText(S.current.enter_seed_phrase); - commonTestCases.hasText(S.current.restore_title_from_seed); - - commonTestCases.hasValueKey('wallet_restore_from_seed_wallet_name_textfield_key'); - commonTestCases.hasValueKey('wallet_restore_from_seed_wallet_name_refresh_button_key'); - commonTestCases.hasValueKey('wallet_restore_from_seed_wallet_seeds_paste_button_key'); - commonTestCases.hasValueKey('wallet_restore_from_seed_wallet_seeds_textfield_key'); - - commonTestCases.hasText(S.current.private_key, hasWidget: false); - commonTestCases.hasText(S.current.restore_title_from_keys, hasWidget: false); - - await commonTestCases.swipePage(); - await commonTestCases.defaultSleepTime(); - - commonTestCases.hasText(S.current.wallet_name); - commonTestCases.hasText(S.current.private_key); - commonTestCases.hasText(S.current.restore_title_from_keys); - - commonTestCases.hasText(S.current.enter_seed_phrase, hasWidget: false); - commonTestCases.hasText(S.current.restore_title_from_seed, hasWidget: false); - - await commonTestCases.swipePage(swipeRight: false); - } - - void confirmRestoreButtonDisplays() { - commonTestCases.hasValueKey('wallet_restore_seed_or_key_restore_button_key'); - } - - void confirmAdvancedSettingButtonDisplays() { - commonTestCases.hasValueKey('wallet_restore_advanced_settings_button_key'); - } - - Future enterWalletNameText(String walletName, {bool isSeedFormEntry = true}) async { - await commonTestCases.enterText( - walletName, - 'wallet_restore_from_${isSeedFormEntry ? 'seed' : 'keys'}_wallet_name_textfield_key', - ); + @override + Future isDisplayed() async { + await isSpecificPage(); } Future selectWalletNameFromAvailableOptions({bool isSeedFormEntry = true}) async { - await commonTestCases.tapItemByKey( + await tapByKey( 'wallet_restore_from_${isSeedFormEntry ? 'seed' : 'keys'}_wallet_name_refresh_button_key', ); } Future enterSeedPhraseForWalletRestore(String text) async { - ValidatableAnnotatedEditableTextState seedTextState = - await tester.state(find.byType(ValidatableAnnotatedEditableText)); + final ValidatableAnnotatedEditableTextState seedTextState = + tester.state(find.byType(ValidatableAnnotatedEditableText)); seedTextState.widget.controller.text = text; - await tester.pumpAndSettle(); + await settle(); } Future enterPasswordForWalletRestore(String text) async { - await commonTestCases.enterText( - text, - 'password', - ); - await tester.pumpAndSettle(); + await enterTextByKey("password", text); + await settle(); } Future enterPasswordRepeatForWalletRestore(String text) async { - await commonTestCases.enterText( - text, - 'repeat_wallet_password', - ); - await tester.pumpAndSettle(); + await enterTextByKey("repeat_wallet_password", text); + await settle(); } Future enterBlockHeightForWalletRestore(String blockHeight) async { - await commonTestCases.enterText( - blockHeight, - 'wallet_restore_from_seed_blockheight_textfield_key', - ); - await tester.pumpAndSettle(); + await enterTextByKey("wallet_restore_from_seed_blockheight_textfield_key", blockHeight); + await settle(); } Future chooseSeedTypeForMoneroOrWowneroWallets(MoneroSeedType selectedType) async { - await commonTestCases.tapItemByKey('wallet_restore_from_seed_seedtype_picker_button_key'); + await tapByKey("wallet_restore_from_seed_seedtype_picker_button_key"); - await commonTestCases.defaultSleepTime(); - - await commonTestCases.tapItemByKey('picker_items_index_${selectedType.title}_button_key'); + await tapByKey("picker_items_index_${selectedType.title}_button_key"); } - Future onPasteSeedPhraseButtonPressed() async { - await commonTestCases.tapItemByKey('wallet_restore_from_seed_wallet_seeds_paste_button_key'); - } - - Future enterPrivateKeyForWalletRestore(String privateKey) async { - await commonTestCases.enterText( - privateKey, - 'wallet_restore_from_key_private_key_textfield_key', + Future expectRestoreRefused({Duration window = const Duration(seconds: 10)}) async { + final button = tester.widget( + find.byKey(const ValueKey("wallet_restore_seed_or_key_restore_button_key")), ); - await tester.pumpAndSettle(); + + expect( + button.isDisabled, + true, + reason: "The restore button accepted a seed the wallet cannot parse", + ); + + final left = await pumpUntil( + () => !tester.any(find.byType(WalletRestorePage)), + timeout: window, + ); + + expect(left, false, reason: "A seed the wallet cannot parse still restored something"); } Future onRestoreWalletButtonPressed() async { - await commonTestCases.tapItemByKey('wallet_restore_seed_or_key_restore_button_key'); - await commonTestCases.defaultSleepTime(); + await tapByKey("wallet_restore_seed_or_key_restore_button_key"); + } + + Future enablePassphrase() async { + await tapByKey("wallet_restore_has_passphrase_checkbox_key"); + await settle(); + + final page = tester.widget(find.byType(WalletRestorePage)); + + expect( + page.walletRestoreViewModel.hasPassphrase, + true, + reason: "The passphrase box did not tick, the wallet would restore without it", + ); + } + + Future enterPassphrase(String passphrase) async { + await enterTextByKey( + "add_passphrase_bottom_sheet_widget_passphrase_textfield_key", + passphrase, + ); + await enterTextByKey( + "add_passphrase_bottom_sheet_widget_confirm_passphrase_textfield_key", + passphrase, + ); + + await tapByKey("add_passphrase_bottom_sheet_widget_restore_button_key"); } } diff --git a/integration_test/robots/restore_options_page_robot.dart b/integration_test/robots/restore_options_page_robot.dart index c269d7343..43448de41 100644 --- a/integration_test/robots/restore_options_page_robot.dart +++ b/integration_test/robots/restore_options_page_robot.dart @@ -1,43 +1,16 @@ -import 'package:cake_wallet/src/screens/restore/restore_options_page.dart'; -import 'package:flutter_test/flutter_test.dart'; +import "package:cake_wallet/src/screens/restore/restore_options_page.dart"; -import '../components/common_test_cases.dart'; +import "../core/base_robot.dart"; -class RestoreOptionsPageRobot { - RestoreOptionsPageRobot(this.tester) : commonTestCases = CommonTestCases(tester); +class RestoreOptionsPageRobot extends BaseRobot { + RestoreOptionsPageRobot(super.tester); - final WidgetTester tester; - late CommonTestCases commonTestCases; - - Future isRestoreOptionsPage() async { - await commonTestCases.isSpecificPage(); - await commonTestCases.takeScreenshots('restore_options'); - } - - void hasRestoreOptionsButton() { - commonTestCases.hasValueKey('restore_options_from_seeds_or_keys_button_key'); - commonTestCases.hasValueKey('restore_options_from_backup_button_key'); - commonTestCases.hasValueKey('restore_options_from_hardware_wallet_button_key'); - commonTestCases.hasValueKey('restore_options_from_qr_button_key'); + @override + Future isDisplayed() async { + await isSpecificPage(); } Future navigateToRestoreFromSeedsOrKeysPage() async { - await commonTestCases.tapItemByKey('restore_options_from_seeds_or_keys_button_key'); - await commonTestCases.defaultSleepTime(); - } - - Future navigateToRestoreFromBackupPage() async { - await commonTestCases.tapItemByKey('restore_options_from_backup_button_key'); - await commonTestCases.defaultSleepTime(); - } - - Future navigateToRestoreFromHardwareWalletPage() async { - await commonTestCases.tapItemByKey('restore_options_from_hardware_wallet_button_key'); - await commonTestCases.defaultSleepTime(); - } - - Future backAndVerify() async { - await commonTestCases.goBack(); - await isRestoreOptionsPage(); + await tapByKey("restore_options_from_seeds_or_keys_button_key"); } } diff --git a/integration_test/robots/security_and_backup_page_robot.dart b/integration_test/robots/security_and_backup_page_robot.dart deleted file mode 100644 index 9bb4c7d44..000000000 --- a/integration_test/robots/security_and_backup_page_robot.dart +++ /dev/null @@ -1,28 +0,0 @@ -import 'package:cake_wallet/generated/i18n.dart'; -import 'package:cake_wallet/src/screens/settings/security_backup_page.dart'; -import 'package:flutter_test/flutter_test.dart'; - -import '../components/common_test_cases.dart'; - -class SecurityAndBackupPageRobot { - SecurityAndBackupPageRobot(this.tester) : commonTestCases = CommonTestCases(tester); - - final WidgetTester tester; - final CommonTestCases commonTestCases; - - Future isSecurityAndBackupPage() async { - await commonTestCases.isSpecificPage(); - await commonTestCases.takeScreenshots('security_backup_page'); - } - - void hasTitle() { - commonTestCases.hasText(S.current.security_and_backup); - } - - Future navigateToShowKeysPage() async { - await tester.pumpAndSettle(Duration(milliseconds: 1000)); - - await commonTestCases.tapItemByKey('security_backup_page_show_keys_button_key'); - await tester.pumpAndSettle(Duration(milliseconds: 1000)); - } -} diff --git a/integration_test/robots/security_backup_page_robot.dart b/integration_test/robots/security_backup_page_robot.dart new file mode 100644 index 000000000..2980b0e69 --- /dev/null +++ b/integration_test/robots/security_backup_page_robot.dart @@ -0,0 +1,16 @@ +import "package:cake_wallet/src/screens/settings/security_backup_page.dart"; + +import "../core/base_robot.dart"; + +class SecurityBackupPageRobot extends BaseRobot { + SecurityBackupPageRobot(super.tester); + + @override + Future isDisplayed() async { + await isSpecificPage(); + } + + Future openChangePin() async { + await tapByKey("security_backup_page_change_pin_button_key"); + } +} diff --git a/integration_test/robots/seed_verification_page_robot.dart b/integration_test/robots/seed_verification_page_robot.dart index a222c2756..e8580bed7 100644 --- a/integration_test/robots/seed_verification_page_robot.dart +++ b/integration_test/robots/seed_verification_page_robot.dart @@ -1,22 +1,19 @@ -import 'package:cake_wallet/generated/i18n.dart'; -import 'package:cake_wallet/src/screens/seed/seed_verification/seed_verification_page.dart'; -import 'package:flutter_test/flutter_test.dart'; +import "package:cake_wallet/generated/i18n.dart"; +import "package:cake_wallet/src/screens/seed/seed_verification/seed_verification_page.dart"; +import "package:flutter_test/flutter_test.dart"; -import '../components/common_test_cases.dart'; +import "../core/base_robot.dart"; -class SeedVerificationPageRobot { - SeedVerificationPageRobot(this.tester) : commonTestCases = CommonTestCases(tester); +class SeedVerificationPageRobot extends BaseRobot { + SeedVerificationPageRobot(super.tester); - final WidgetTester tester; - final CommonTestCases commonTestCases; - - Future isSeedVerificationPage() async { - await commonTestCases.isSpecificPage(); - await commonTestCases.takeScreenshots('seed_verification_page'); + @override + Future isDisplayed() async { + await isSpecificPage(); } void hasTitle() { - commonTestCases.hasText(S.current.verify_seed); + hasText(S.current.verify_seed); } Future verifyWalletSeeds() async { @@ -28,18 +25,23 @@ class SeedVerificationPageRobot { while (!walletSeedViewModel.isVerificationComplete && walletSeedViewModel.verificationWordCount != 0) { final currentCorrectWord = walletSeedViewModel.currentCorrectWord; + final currentStep = walletSeedViewModel.currentStepIndex; - commonTestCases.hasTextAtLestOnce(currentCorrectWord); + hasTextAtLeastOnce(currentCorrectWord); - await commonTestCases.tapItemByKey( - 'seed_verification_option_${currentCorrectWord}_button_key', + await tapByKey("seed_verification_option_${currentCorrectWord}_button_key"); + + final hasMovedOn = await pumpUntil( + () => + walletSeedViewModel.isVerificationComplete || + walletSeedViewModel.currentStepIndex != currentStep, ); - await commonTestCases.defaultSleepTime(seconds: 1); + if (!hasMovedOn) { + throw TestFailure("Seed verification never moved past step $currentStep"); + } } - await commonTestCases.tapItemByKey('wallet_seed_page_open_wallet_button_key'); - - await commonTestCases.defaultSleepTime(); + await tapByKey("wallet_seed_page_open_wallet_button_key"); } } diff --git a/integration_test/robots/send_page_robot.dart b/integration_test/robots/send_page_robot.dart deleted file mode 100644 index 84d0156ea..000000000 --- a/integration_test/robots/send_page_robot.dart +++ /dev/null @@ -1,716 +0,0 @@ -import 'dart:async'; - -import 'package:cake_wallet/core/execution_state.dart'; -import 'package:cake_wallet/generated/i18n.dart'; -import 'package:cake_wallet/src/screens/send/send_page.dart'; -import 'package:cake_wallet/src/widgets/base_text_form_field.dart'; -import 'package:cake_wallet/src/widgets/primary_button.dart'; -import 'package:cake_wallet/view_model/send/send_view_model_state.dart'; -import 'package:cw_core/crypto_currency.dart'; -import 'package:cw_core/transaction_priority.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; - -import '../components/common_test_cases.dart'; -import '../components/common_test_constants.dart'; -import 'auth_page_robot.dart'; -import 'package:cake_wallet/src/widgets/standard_slide_button_widget.dart'; -import 'package:cake_wallet/src/screens/dashboard/dashboard_page.dart'; - -class SendPageRobot { - SendPageRobot({required this.tester}) - : commonTestCases = CommonTestCases(tester), - authPageRobot = AuthPageRobot(tester); - - WidgetTester tester; - CommonTestCases commonTestCases; - AuthPageRobot authPageRobot; - - Future isSendPage() async { - await commonTestCases.isSpecificPage(); - await commonTestCases.takeScreenshots('send_page'); - } - - Future waitForSendPage() async { - tester.printToConsole('Waiting for SendPage to be available'); - - final stopwatch = Stopwatch()..start(); - final maxDuration = Duration(seconds: 30); - - while (stopwatch.elapsed < maxDuration) { - await tester.pump(Duration(milliseconds: 500)); - - // Check if we're still on auth page - if (authPageRobot.onAuthPage()) { - tester.printToConsole('Still on auth page, waiting...'); - continue; - } - - // Check if SendPage is available - final sendPageFinder = find.byType(SendPage); - if (sendPageFinder.tryEvaluate()) { - tester.printToConsole('SendPage found!'); - return; - } - - tester.printToConsole('SendPage not found yet, waiting...'); - } - - throw Exception('SendPage not found after ${maxDuration.inSeconds} seconds'); - } - - Future checkIfSendPageIsVisible() async { - tester.printToConsole('Confirming SendPage is visible'); - - if (authPageRobot.onAuthPage()) { - tester.printToConsole('Auth page is currently visible'); - await _handleAuthPage(); - await commonTestCases.defaultSleepTime(); - } - - await waitForSendPage(); - } - - void hasTitle() { - commonTestCases.hasText(S.current.send); - } - - void confirmViewComponentsDisplayProperly() { - SendPage sendPage = tester.widget(find.byType(SendPage)); - final sendViewModel = sendPage.sendViewModel; - - commonTestCases.hasValueKey('send_page_address_textfield_key'); - commonTestCases.hasValueKey('send_page_note_textfield_key'); - commonTestCases.hasValueKey('send_page_amount_textfield_key'); - commonTestCases.hasValueKey('send_page_add_template_button_key'); - - if (sendViewModel.hasMultipleTokens) { - commonTestCases.hasValueKey('send_page_currency_picker_button_key'); - } - - if (!sendViewModel.isBatchSending) { - commonTestCases.hasValueKey('send_page_send_all_button_key'); - } - - if (!sendViewModel.isFiatDisabled) { - commonTestCases.hasValueKey('send_page_fiat_amount_textfield_key'); - } - - if (sendViewModel.feesViewModel.hasFees) { - commonTestCases.hasValueKey('send_page_select_fee_priority_button_key'); - } - - if (sendViewModel.hasCoinControl) { - commonTestCases.hasValueKey('send_page_unspent_coin_button_key'); - } - - if (sendViewModel.sendTemplateViewModel.hasMultiRecipient) { - commonTestCases.hasValueKey('send_page_add_receiver_button_key'); - } - } - - Future selectReceiveCurrency(CryptoCurrency receiveCurrency) async { - final currencyPickerKey = 'send_page_currency_picker_button_key'; - final currencyPickerDialogKey = 'send_page_currency_picker_dialog_button_key'; - - await commonTestCases.tapItemByKey(currencyPickerKey); - await commonTestCases.defaultSleepTime(); - - // Check if picker dialog is present - if (!commonTestCases.isKeyPresent(currencyPickerDialogKey)) { - tester.printToConsole('Currency picker dialog not found, may already be selected'); - return; - } - - SendPage sendPage = tester.widget(find.byType(SendPage)); - final sendViewModel = sendPage.sendViewModel; - - if (receiveCurrency == sendViewModel.selectedCryptoCurrency) { - await commonTestCases - .tapItemByKey('picker_items_index_${receiveCurrency.name}_selected_item_button_key'); - return; - } - - await commonTestCases.enterText(receiveCurrency.title, 'search_bar_widget_key'); - - await commonTestCases.defaultSleepTime(); - - await commonTestCases.tapItemByKey('picker_items_index_${receiveCurrency.fullName}_button_key'); - } - - Future enterReceiveAddress(String receiveAddress) async { - await commonTestCases.enterText(receiveAddress, 'send_page_address_textfield_key'); - await commonTestCases.defaultSleepTime(); - } - - Future enterSendAmount(String amount, {bool isFiat = false}) async { - await commonTestCases.enterText( - amount, - isFiat ? 'send_page_fiat_amount_textfield_key' : 'send_page_amount_textfield_key', - ); - } - - String _getTextFromField(ValueKey key) { - final field = find.byKey(key); - if (!field.tryEvaluate()) return ''; - final baseTextFormField = tester.widget(field); - return baseTextFormField.controller?.text ?? ''; - } - - /// Validates wallet balance for $1 send by checking against the converted crypto amount - /// Returns true if wallet has sufficient balance, false otherwise - Future validateWalletBalanceForOneDollarSend() async { - SendPage sendPage = tester.widget(find.byType(SendPage)); - final sendViewModel = sendPage.sendViewModel; - - final balance = await sendViewModel.sendingBalance; - - await setupOneDollarSend(); - - // Get the crypto amount that was set for the $1 send - String cryptoAmount = _getTextFromField(ValueKey('send_page_amount_textfield_key')); - if (cryptoAmount.isEmpty || cryptoAmount == '0' || cryptoAmount == '0.0') { - cryptoAmount = '0.001'; // fallback amount - } - - final amount = double.tryParse(cryptoAmount) ?? 0.0; - - tester.printToConsole( - 'Wallet balance: $balance, sending amount for \$${CommonTestConstants.sendTestFiatAmount}: $cryptoAmount', - ); - - if (balance.isEmpty || double.tryParse(balance) == null) { - tester.printToConsole('Invalid wallet balance: $balance'); - return false; - } - - final balanceValue = double.parse(balance); - if (balanceValue < amount) { - tester.printToConsole( - 'Insufficient balance for \$${CommonTestConstants.sendTestFiatAmount} send: $balanceValue < $amount', - ); - return false; - } - - tester.printToConsole( - 'Wallet has sufficient balance for \$${CommonTestConstants.sendTestFiatAmount} send', - ); - return true; - } - - Future selectTransactionPriority({TransactionPriority? priority}) async { - SendPage sendPage = tester.widget(find.byType(SendPage)); - final sendViewModel = sendPage.sendViewModel; - - if (!sendViewModel.feesViewModel.hasFees || priority == null) return; - - final transactionPriorityPickerKey = 'send_page_select_fee_priority_button_key'; - await commonTestCases.tapItemByKey(transactionPriorityPickerKey); - - if (priority == sendViewModel.feesViewModel.transactionPriority) { - await commonTestCases - .tapItemByKey('picker_items_index_${priority.title}_selected_item_button_key'); - return; - } - - await commonTestCases.dragUntilVisible( - 'picker_items_index_${priority.title}_button_key', - 'picker_scrollbar_key', - ); - await commonTestCases.defaultSleepTime(); - - await commonTestCases.tapItemByKey('picker_items_index_${priority.title}_button_key'); - } - - Future onSendButtonPressed() async { - tester.printToConsole('Pressing send'); - - await checkIfSendPageIsVisible(); - - await tester.pumpAndSettle(); - final sendPage = tester.widget(find.byType(SendPage)); - - while (true) { - bool isReadyForSend = sendPage.sendViewModel.isReadyForSend; - await tester.pump(); - if (isReadyForSend) { - tester.printToConsole('Is ready for send'); - break; - } else { - await commonTestCases.defaultSleepTime(); - await tester.pumpAndSettle(); - tester.printToConsole('not yet ready for send'); - } - } - await commonTestCases.tapItemByKey( - 'send_page_send_button_key', - shouldPumpAndSettle: false, - ); - - await _waitForSendTransactionCompletion(); - - await commonTestCases.defaultSleepTime(); - } - - Future _waitForSendTransactionCompletion() async { - await tester.pump(); - final Completer completer = Completer(); - - // Loop to wait for the async operation to complete - while (true) { - await Future.delayed(Duration(seconds: 1)); - - tester.printToConsole('Before _handleAuth'); - - await _handleAuthPage(); - - await commonTestCases.defaultSleepTime(); - - tester.printToConsole('After _handleAuth'); - - await tester.pump(); - - if (authPageRobot.onAuthPage()) { - tester.printToConsole('Still on auth page, continuing to wait'); - continue; - } - - final sendPageFinder = find.byType(SendPage); - if (!sendPageFinder.tryEvaluate()) { - tester.printToConsole('SendPage not found yet, continuing to wait'); - continue; - } - - final sendPage = tester.widget(sendPageFinder); - final state = sendPage.sendViewModel.state; - - await tester.pump(); - - bool isDone = state is ExecutedSuccessfullyState || state is TransactionCommitted; - bool isFailed = state is FailureState; - - tester.printToConsole('isDone: $isDone'); - tester.printToConsole('isFailed: $isFailed'); - - if (isDone || isFailed) { - tester.printToConsole( - isDone ? 'Completer is done' : 'Completer is done though operation failed', - ); - completer.complete(); - await tester.pump(); - break; - } else { - tester.printToConsole('Completer is not done'); - await tester.pump(); - } - } - - await expectLater(completer.future, completes); - - tester.printToConsole('Done confirming sending operation'); - } - - Future _handleAuthPage() async { - tester.printToConsole('Inside _handleAuth'); - - final onAuthPageDesktop = authPageRobot.onAuthPageDesktop(); - if (onAuthPageDesktop) { - await authPageRobot.enterPassword(CommonTestConstants.pin.join("")); - await commonTestCases.defaultSleepTime(); - return; - } - - await tester.pump(); - tester.printToConsole('starting auth checks'); - - final authPage = authPageRobot.onAuthPage(); - - tester.printToConsole('hasAuth:$authPage'); - - if (authPage) { - await tester.pump(); - tester.printToConsole('Starting inner _handleAuth loop checks'); - - try { - await authPageRobot.enterPinCode(CommonTestConstants.pin, pumpDuration: 500); - tester.printToConsole('Auth done'); - - await tester.pump(Duration(seconds: 3)); - - tester.printToConsole('Auth pump done'); - - await commonTestCases.defaultSleepTime(); - } catch (e) { - tester.printToConsole('Auth failed, retrying: $e'); - await tester.pump(); - - await commonTestCases.defaultSleepTime(seconds: 1); - await _handleAuthPage(); - } - } else { - tester.printToConsole('No auth page detected, proceeding'); - } - await tester.pump(); - } - - //* ------ On Sending Failure ------------ - Future hasErrorWhileSending() async { - await tester.pump(); - - tester.printToConsole('Checking if there is an error'); - - final errorDialog = find.byKey(ValueKey('send_page_send_failure_dialog_button_key')); - - bool hasError = errorDialog.tryEvaluate(); - - tester.printToConsole('Has error: $hasError'); - - return hasError; - } - - Future onSendFailureDialogButtonPressed() async { - await commonTestCases.defaultSleepTime(); - - tester.printToConsole('Send Button Failure Dialog Triggered'); - - await commonTestCases.tapItemByKey('send_page_send_failure_dialog_button_key'); - } - - //* ------ On Sending Success ------------ - Future onSendSliderOnConfirmSendingBottomSheetDragged() async { - await commonTestCases.defaultSleepTime(); - await tester.pump(); - - if (commonTestCases.isKeyPresent('send_page_confirm_sending_bottom_sheet_key')) { - tester.printToConsole('Found confirm sending bottom sheet, starting slider drag'); - - final accessibleButton = find.byType(PrimaryButton); - if (accessibleButton.tryEvaluate()) { - tester.printToConsole('Found accessible navigation button, tapping it'); - await tester.tap(accessibleButton); - await tester.pumpAndSettle(); - } else { - await _performSendSliderDrag(); - } - - tester.printToConsole('Slider/button action completed, waiting for transaction completion'); - - // Check if bottom sheet is dismissed - if (commonTestCases.isKeyPresent('send_page_confirm_sending_bottom_sheet_key')) { - tester.printToConsole('Bottom sheet still present, waiting a bit more for dismissal'); - await commonTestCases.defaultSleepTime(seconds: 3); - - // If still present, try one more manual dismissal attempt - if (commonTestCases.isKeyPresent('send_page_confirm_sending_bottom_sheet_key')) { - tester.printToConsole('Bottom sheet still present, attempting final manual dismissal'); - await tester.tapAt(Offset(200, 200)); // Tap outside - await tester.pumpAndSettle(); - } - } - - // Wait for transaction completion - await _waitForCommitTransactionCompletion(); - await commonTestCases.defaultSleepTime(seconds: 2); - } else { - tester.printToConsole('Confirm sending bottom sheet not found, waiting and retrying'); - await commonTestCases.defaultSleepTime(); - await tester.pump(); - await onSendSliderOnConfirmSendingBottomSheetDragged(); - } - } - - Future _performSendSliderDrag() async { - final sliderFinder = find.byKey(const ValueKey('standard_slide_button_widget_slider_key')); - expect(sliderFinder, findsOneWidget, reason: 'Slider should be found'); - - // Find the StandardSlideButton widget to get the main container - final slideButtonFinder = find.byType(StandardSlideButton); - expect(slideButtonFinder, findsOneWidget, reason: 'StandardSlideButton should be found'); - - // Get the main container bounds (the outer container, not the slider container) - final mainContainerRect = tester.getRect(slideButtonFinder); - final containerWidth = mainContainerRect.width; - - final sideMargin = 4.0; - final sliderWidth = 42.0; - final effectiveMaxWidth = containerWidth - 2 * sideMargin; - final threshold = effectiveMaxWidth - sliderWidth - 10; - - // Add a small buffer to ensure we exceed the threshold - final dragDistance = threshold + 20; - - tester.printToConsole( - 'Main container width: $containerWidth, Threshold: $threshold, Drag distance: $dragDistance', - ); - - // Start the drag - await tester.drag(sliderFinder, Offset(dragDistance, 0)); - - // Wait for the drag to complete and trigger onHorizontalDragEnd - await tester.pump(Duration(milliseconds: 100)); - - // Release the drag (this should trigger onHorizontalDragEnd) - await tester.pump(Duration(seconds: 2)); - - tester.printToConsole('Drag completed, waiting for callback'); - - // Wait for the slide completion callback to trigger and bottom sheet to dismiss - await _waitForBottomSheetDismissal(); - } - - Future _waitForBottomSheetDismissal() async { - tester.printToConsole('Waiting for bottom sheet dismissal'); - - final stopwatch = Stopwatch()..start(); - final maxDuration = Duration(seconds: 10); - - while (stopwatch.elapsed < maxDuration) { - await tester.pump(Duration(milliseconds: 500)); - - // Check if the confirm bottom sheet is still present - if (!commonTestCases.isKeyPresent('send_page_confirm_sending_bottom_sheet_key')) { - tester.printToConsole('Bottom sheet dismissed successfully!'); - return; - } - - // Check if transaction has started (this indicates slider was successful) - if (_isTransactionStarted()) { - tester.printToConsole( - 'Transaction started, slider was successful even if bottom sheet is still visible'); - return; - } - - tester.printToConsole('Bottom sheet still present, waiting'); - } - - tester.printToConsole('Bottom sheet dismissal timeout reached, trying manual dismissal'); - - // If the bottom sheet is still present after timeout, try to manually dismiss it - // This might happen if the slider drag didn't trigger the onSlideComplete properly - if (commonTestCases.isKeyPresent('send_page_confirm_sending_bottom_sheet_key')) { - tester.printToConsole('Attempting manual bottom sheet dismissal'); - - // Try to find and tap a close button or back button - final closeButton = find.byIcon(Icons.close); - if (closeButton.tryEvaluate()) { - await tester.tap(closeButton); - await tester.pumpAndSettle(); - tester.printToConsole('Manual close button tapped'); - } else { - // Try to pop the bottom sheet by tapping outside or using back gesture - await tester.tapAt(Offset(100, 100)); // Tap outside the bottom sheet - await tester.pumpAndSettle(); - tester.printToConsole('Tapped outside bottom sheet'); - } - } - } - - bool _isTransactionStarted() { - try { - // Check if SendPage is available and transaction state has changed - final sendPageFinder = find.byType(SendPage); - if (!sendPageFinder.tryEvaluate()) { - return false; - } - - final sendPage = tester.widget(sendPageFinder); - final state = sendPage.sendViewModel.state; - - // Check if we're in a transaction-related state - return state is TransactionCommitting || - state is IsExecutingState || - state is TransactionCommitted || - state is ExecutedSuccessfullyState; - } catch (e) { - return false; - } - } - - Future _waitForCommitTransactionCompletion() async { - tester.printToConsole('Starting to wait for transaction completion'); - - final stopwatch = Stopwatch()..start(); - final maxDuration = Duration(seconds: 60); - - while (stopwatch.elapsed < maxDuration) { - await Future.delayed(Duration(seconds: 1)); - - // Check if the confirm bottom sheet is gone (transaction started) - if (!commonTestCases.isKeyPresent('send_page_confirm_sending_bottom_sheet_key')) { - tester.printToConsole('Confirm bottom sheet disappeared, transaction may be processing'); - } - - // Check if we've navigated back to the dashboard (for cases of successful transaction) - final dashboardFinder = find.byType(DashboardPage); - if (dashboardFinder.tryEvaluate()) { - tester.printToConsole('Dashboard detected, transaction completed successfully!'); - await tester.pump(); - return; - } - - // Check if SendPage is still available (transaction still processing) - final sendPageFinder = find.byType(SendPage); - if (sendPageFinder.tryEvaluate()) { - final sendPage = tester.widget(sendPageFinder); - final state = sendPage.sendViewModel.state; - - bool isDone = state is ExecutedSuccessfullyState || state is TransactionCommitted; - bool isFailed = state is FailureState; - - tester.printToConsole('Transaction state: $state'); - tester.printToConsole('isDone: $isDone'); - tester.printToConsole('isFailed: $isFailed'); - - if (isDone) { - tester.printToConsole('Transaction committed successfully!'); - await tester.pump(); - return; - } else if (isFailed) { - tester.printToConsole('Transaction failed: $state'); - await tester.pump(); - return; - } else { - tester.printToConsole('Transaction still processing'); - await tester.pump(); - } - } else { - // SendPage not found, check if we're on dashboard or still processing - if (dashboardFinder.tryEvaluate()) { - tester.printToConsole('Dashboard detected - transaction completed successfully!'); - await tester.pump(); - return; - } else { - tester.printToConsole( - 'SendPage not found, but dashboard not yet visible - continuing to wait...'); - } - } - } - - if (stopwatch.elapsed >= maxDuration) { - tester.printToConsole('Transaction completion timeout reached'); - } - - tester.printToConsole('Done waiting for transaction completion'); - } - - //* ---- Handle Transaction Success Flow ----- - Future handleTransactionSuccessFlow() async { - await commonTestCases.defaultSleepTime(); - - // Wait for any success dialogs to appear - await tester.pump(Duration(seconds: 2)); - - // Check for contact addition dialog first (if new contact address exists) - final contactDialog = find.byKey(ValueKey('send_page_add_contact_bottom_sheet_yes_button_key')); - if (contactDialog.tryEvaluate()) { - tester.printToConsole('Found contact addition dialog, selecting Yes'); - - // Check if the button is actually visible and tappable - final buttonRect = tester.getRect(contactDialog); - final screenSize = tester.view.physicalSize / tester.view.devicePixelRatio; - - if (buttonRect.bottom <= screenSize.height && buttonRect.top >= 0) { - await commonTestCases.tapItemByKey('send_page_add_contact_bottom_sheet_yes_button_key'); - await commonTestCases.defaultSleepTime(); - } else { - tester.printToConsole('Contact dialog button is off-screen, skipping'); - } - } - - // Check for the main success dialog - final successDialog = find.byKey(ValueKey('send_page_sent_dialog_ok_button_key')); - if (successDialog.tryEvaluate()) { - tester.printToConsole('Found transaction success dialog, closing it'); - await commonTestCases.tapItemByKey('send_page_sent_dialog_ok_button_key'); - await commonTestCases.defaultSleepTime(); - } - } - - //* ---- Fiat/Crypto Amount Validation ----- - Future testFiatAmountEntry() async { - tester.printToConsole('Testing fiat amount entry...'); - - await enterSendAmount(''); - await enterSendAmount('', isFiat: true); - await commonTestCases.defaultSleepTime(); - - await enterSendAmount(CommonTestConstants.sendTestFiatAmount, isFiat: true); - await commonTestCases.defaultSleepTime(); - - // Wait for conversion to complete - await tester.pump(Duration(seconds: 3)); - await tester.pumpAndSettle(); - - // Next we get the crypto amount value and validate it's not 0 - final cryptoAmount = _getTextFromField(ValueKey('send_page_amount_textfield_key')); - - tester.printToConsole( - 'Crypto amount after entering \$${CommonTestConstants.sendTestFiatAmount}: $cryptoAmount', - ); - - if (cryptoAmount.isNotEmpty && cryptoAmount != '0' && cryptoAmount != '0.0') { - tester.printToConsole('Fiat to crypto conversion working - crypto amount: $cryptoAmount'); - } else { - tester.printToConsole( - 'Fiat to crypto conversion may not be working - crypto amount: $cryptoAmount', - ); - } - } - - Future testCryptoAmountEntry() async { - tester.printToConsole('Testing crypto amount entry...'); - - await enterSendAmount(''); - await enterSendAmount('', isFiat: true); - await commonTestCases.defaultSleepTime(); - - String cryptoAmount = '0.001'; - - await enterSendAmount(cryptoAmount); - await commonTestCases.defaultSleepTime(); - - // Wait for conversion to complete - await tester.pump(Duration(seconds: 3)); - await tester.pumpAndSettle(); - - // Get the fiat amount value and validate it's not 0 - final fiatAmount = _getTextFromField(ValueKey('send_page_fiat_amount_textfield_key')); - - tester.printToConsole('Fiat amount after entering $cryptoAmount: $fiatAmount'); - - if (fiatAmount.isNotEmpty && fiatAmount != '0' && fiatAmount != '0.0') { - tester.printToConsole('Crypto to fiat conversion working, fiat amount: $fiatAmount'); - } else { - tester.printToConsole( - 'Crypto to fiat conversion may not be working - fiat amount: $fiatAmount'); - } - } - - Future setupOneDollarSend() async { - // Clear existing amounts - await enterSendAmount(''); - await enterSendAmount('', isFiat: true); - await commonTestCases.defaultSleepTime(); - - await enterSendAmount(CommonTestConstants.sendTestFiatAmount, isFiat: true); - await commonTestCases.defaultSleepTime(); - - // Wait for conversion to complete - await tester.pump(Duration(seconds: 3)); - await tester.pumpAndSettle(); - - // Get the converted crypto amount - String cryptoAmount = _getTextFromField(ValueKey('send_page_amount_textfield_key')); - if (cryptoAmount.isEmpty || cryptoAmount == '0' || cryptoAmount == '0.0') { - cryptoAmount = '0.001'; // fallback amount - } - - tester.printToConsole( - 'Sending $cryptoAmount (equivalent to \$${CommonTestConstants.sendTestFiatAmount}) to test wallet', - ); - - // Update the amount field with the converted value - await enterSendAmount(cryptoAmount); - await commonTestCases.defaultSleepTime(); - } -} diff --git a/integration_test/robots/setup_pin_code_robot.dart b/integration_test/robots/setup_pin_code_robot.dart index aa6dde070..1cb2318e1 100644 --- a/integration_test/robots/setup_pin_code_robot.dart +++ b/integration_test/robots/setup_pin_code_robot.dart @@ -1,29 +1,16 @@ -import 'package:cake_wallet/generated/i18n.dart'; -import 'package:cake_wallet/src/screens/setup_pin_code/setup_pin_code.dart'; -import 'package:flutter_test/flutter_test.dart'; +import "package:cake_wallet/src/screens/setup_pin_code/setup_pin_code.dart"; -import '../components/common_test_cases.dart'; -import 'pin_code_widget_robot.dart'; +import "pin_code_widget_robot.dart"; class SetupPinCodeRobot extends PinCodeWidgetRobot { - SetupPinCodeRobot(this.tester) - : commonTestCases = CommonTestCases(tester), - super(tester); + SetupPinCodeRobot(super.tester); - final WidgetTester tester; - late CommonTestCases commonTestCases; - - Future isSetupPinCodePage() async { - await commonTestCases.isSpecificPage(); - await commonTestCases.takeScreenshots('setup_pin_code_page'); - } - - void hasTitle() { - commonTestCases.hasText(S.current.setup_pin); + @override + Future isDisplayed() async { + await isSpecificPage(); } Future tapSuccessButton() async { - await commonTestCases.tapItemByKey('setup_pin_code_success_button_key'); - await commonTestCases.defaultSleepTime(); + await tapByKey("setup_pin_code_success_button_key"); } } diff --git a/integration_test/robots/show_keys_disclaimer_page_robot.dart b/integration_test/robots/show_keys_disclaimer_page_robot.dart new file mode 100644 index 000000000..8d0caec5c --- /dev/null +++ b/integration_test/robots/show_keys_disclaimer_page_robot.dart @@ -0,0 +1,16 @@ +import "package:cake_wallet/new-ui/pages/seed/show_keys_disclaimer_page.dart"; + +import "../core/base_robot.dart"; + +class ShowKeysDisclaimerPageRobot extends BaseRobot { + ShowKeysDisclaimerPageRobot(super.tester); + + @override + Future isDisplayed() async { + await isSpecificPage(); + } + + Future tapShowKeys() async { + await tapByKey("show_keys_disclaimer_page_button_key"); + } +} diff --git a/integration_test/robots/transaction_success_info_robot.dart b/integration_test/robots/transaction_success_info_robot.dart deleted file mode 100644 index 4be484ac5..000000000 --- a/integration_test/robots/transaction_success_info_robot.dart +++ /dev/null @@ -1,21 +0,0 @@ -import 'package:cake_wallet/src/screens/send/transaction_success_info_page.dart'; -import 'package:flutter_test/flutter_test.dart'; - -import '../components/common_test_cases.dart'; - -class TransactionSuccessInfoRobot { - TransactionSuccessInfoRobot(this.tester) : commonTestCases = CommonTestCases(tester); - - final WidgetTester tester; - late CommonTestCases commonTestCases; - - Future isTransactionSuccessInfoPage() async { - await commonTestCases.isSpecificPage(); - await commonTestCases.takeScreenshots('transaction_success_info_page'); - } - - Future onConfirmButtonPressed() async { - await commonTestCases.tapItemByKey('transaction_success_info_page_button_key'); - await commonTestCases.defaultSleepTime(); - } -} diff --git a/integration_test/robots/transactions_page_robot.dart b/integration_test/robots/transactions_page_robot.dart deleted file mode 100644 index 1365c54c2..000000000 --- a/integration_test/robots/transactions_page_robot.dart +++ /dev/null @@ -1,439 +0,0 @@ -import 'package:cake_wallet/generated/i18n.dart'; -import 'package:cake_wallet/src/screens/dashboard/pages/transactions_page.dart'; -import 'package:cake_wallet/view_model/dashboard/anonpay_transaction_list_item.dart'; -import 'package:cake_wallet/view_model/dashboard/dashboard_view_model.dart'; -import 'package:cake_wallet/view_model/dashboard/date_section_item.dart'; -import 'package:cake_wallet/view_model/dashboard/order_list_item.dart'; -import 'package:cake_wallet/view_model/dashboard/trade_list_item.dart'; -import 'package:cake_wallet/view_model/dashboard/transaction_list_item.dart'; -import 'package:cw_core/crypto_currency.dart'; -import 'package:cw_core/sync_status.dart'; -import 'package:cw_core/transaction_direction.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:intl/intl.dart'; - -import '../components/common_test_cases.dart'; - -class TransactionsPageRobot { - TransactionsPageRobot(this.tester) : commonTestCases = CommonTestCases(tester); - - final WidgetTester tester; - late CommonTestCases commonTestCases; - - Future isTransactionsPage() async { - await commonTestCases.isSpecificPage(); - await commonTestCases.takeScreenshots('transactions_page'); - } - - Future confirmTransactionsPageConstantsDisplayProperly() async { - await commonTestCases.defaultSleepTime(); - - final transactionsPage = tester.widget(find.byType(TransactionsPage)); - final dashboardViewModel = transactionsPage.dashboardViewModel; - if (dashboardViewModel.status is SyncingSyncStatus) { - commonTestCases.hasValueKey('transactions_page_syncing_alert_card_key'); - commonTestCases.hasText(S.current.syncing_wallet_alert_title); - commonTestCases.hasText(S.current.syncing_wallet_alert_content); - } - - commonTestCases.hasValueKey('transactions_page_header_row_key'); - commonTestCases.hasText(S.current.transactions); - commonTestCases.hasValueKey('transactions_page_header_row_transaction_filter_button_key'); - } - - Future confirmTransactionHistoryListDisplaysCorrectly(bool hasTxHistoryWhileSyncing) async { - try { - final transactionsPage = tester.widget(find.byType(TransactionsPage)); - final dashboardViewModel = transactionsPage.dashboardViewModel; - - await _waitForListToBeReady(); - - if (!hasTxHistoryWhileSyncing) { - await _waitForSyncToComplete(dashboardViewModel); - } else { - await tester.pump(Duration(seconds: 2)); - await tester.pumpAndSettle(); - } - - await _performComprehensiveItemCheck(dashboardViewModel); - } catch (e) { - tester.printToConsole('Error in transaction history check: $e'); - } - } - - Future _waitForSyncToComplete(DashboardViewModel dashboardViewModel) async { - const maxWaitTime = Duration(minutes: 2); - final endTime = DateTime.now().add(maxWaitTime); - - tester.printToConsole('Waiting for wallet to sync...'); - - while (DateTime.now().isBefore(endTime)) { - await tester.pump(Duration(seconds: 2)); - await tester.pumpAndSettle(); - - if (dashboardViewModel.status is SyncedSyncStatus) { - tester.printToConsole('Wallet synced successfully'); - return; - } - - // Check if we have now have transaction items available - if (dashboardViewModel.items.isNotEmpty) { - tester.printToConsole('Items available while syncing, proceeding with test'); - return; - } - - tester.printToConsole('Sync status: ${dashboardViewModel.status.runtimeType}'); - } - - tester.printToConsole('Warning: Wallet did not sync within expected time, proceeding anyway'); - } - - Future _performComprehensiveItemCheck(DashboardViewModel dashboardViewModel) async { - try { - await _waitForItemsToLoad(dashboardViewModel); - - final itemsToProcess = dashboardViewModel.items.where((item) { - if (item is DateSectionItem) return false; - if (item is TransactionListItem) { - return !(item.hasTokens && item.assetOfTransaction == null); - } - return true; - }).toList(); - - if (itemsToProcess.isEmpty) { - tester.printToConsole('No transaction items to process - checking for placeholder'); - _verifyPlaceholder(); - return; - } - - // This is a temporary limit to prevent the test from taking too long - final maxItemsToProcess = 100; - final itemsToCheck = itemsToProcess.take(maxItemsToProcess).toList(); - - tester.printToConsole( - 'Processing ${itemsToCheck.length} items out of ${itemsToProcess.length} total items', - ); - - await _processVisibleItems(itemsToCheck, dashboardViewModel); - - // Try to scroll and process more items if needed - await _processItemsWithScrolling(itemsToCheck, dashboardViewModel); - } catch (e) { - tester.printToConsole('Error in comprehensive item check: $e'); - try { - _verifyPlaceholder(); - } catch (placeholderError) { - tester.printToConsole('Could not verify placeholder either: $placeholderError'); - } - } - } - - Future _waitForItemsToLoad(DashboardViewModel dashboardViewModel) async { - const maxWaitTime = Duration(seconds: 30); - final endTime = DateTime.now().add(maxWaitTime); - - while (DateTime.now().isBefore(endTime)) { - if (dashboardViewModel.items.isNotEmpty) { - tester.printToConsole('Items loaded: ${dashboardViewModel.items.length}'); - return; - } - - // Check if placeholder is shown - if (tester.any(find.byKey(ValueKey('transactions_page_placeholder_transactions_text_key')))) { - tester.printToConsole('Placeholder is shown - no items to load'); - return; - } - - await tester.pump(Duration(seconds: 1)); - await tester.pumpAndSettle(); - } - - tester.printToConsole('Warning: No items loaded and no placeholder shown within expected time'); - } - - Future _processVisibleItems( - List items, - DashboardViewModel dashboardViewModel, - ) async { - try { - int processedCount = 0; - - for (var item in items) { - try { - final keyId = (item.key as ValueKey).value; - - // Check if item is already visible - if (tester.any(find.byKey(ValueKey(keyId)))) { - tester.printToConsole('Processing visible item: $keyId'); - await _verifyItemDisplay(item, dashboardViewModel); - processedCount++; - } - } catch (itemError) { - tester.printToConsole('Error processing item: $itemError'); - continue; - } - } - - tester.printToConsole('Processed $processedCount visible items\n'); - } catch (e) { - tester.printToConsole('Error in visible items processing: $e'); - } - } - - Future _processItemsWithScrolling( - List items, - DashboardViewModel dashboardViewModel, - ) async { - try { - final scrollableFinder = find.descendant( - of: find.byKey(ValueKey('transactions_page_list_view_builder_key')), - matching: find.byType(Scrollable), - ); - - if (!tester.any(scrollableFinder)) { - tester.printToConsole('No scrollable found, skipping scroll processing'); - return; - } - - int processedCount = 0; - const maxScrollAttempts = 10; - - for (var item in items) { - try { - final keyId = (item.key as ValueKey).value; - - // Skip if already processed - if (tester.any(find.byKey(ValueKey(keyId)))) { - continue; - } - - // Try to scroll to the item - bool found = false; - for (int attempt = 0; attempt < maxScrollAttempts; attempt++) { - await tester.pumpAndSettle(Duration(milliseconds: 200)); - - if (tester.any(find.byKey(ValueKey(keyId)))) { - tester.printToConsole('Found item after scrolling: $keyId'); - await _verifyItemDisplay(item, dashboardViewModel); - processedCount++; - found = true; - break; - } - - // Perform scroll - try { - await tester.drag(scrollableFinder, Offset(0, -100)); - await tester.pumpAndSettle(Duration(milliseconds: 300)); - } catch (scrollError) { - tester.printToConsole('Scroll failed: $scrollError'); - break; - } - } - - if (!found) { - tester.printToConsole('Could not find item after scrolling: $keyId'); - } - } catch (itemError) { - tester.printToConsole('Error processing item with scrolling: $itemError'); - continue; - } - } - - tester.printToConsole('Processed $processedCount items with scrolling'); - } catch (e) { - tester.printToConsole('Error in scroll processing: $e'); - } - } - - void _verifyPlaceholder() { - commonTestCases.hasValueKey('transactions_page_placeholder_transactions_text_key'); - commonTestCases.hasText(S.current.placeholder_transactions); - } - - Future _waitForListToBeReady() async { - const maxWaitAttempts = 10; - int attempts = 0; - - while (attempts < maxWaitAttempts) { - await tester.pumpAndSettle(Duration(milliseconds: 500)); - - // Check if the list view is present - final listViewFinder = find.byKey(ValueKey('transactions_page_list_view_builder_key')); - if (tester.any(listViewFinder)) { - tester.printToConsole('List view is ready'); - return; - } - - attempts++; - tester.printToConsole('Waiting for list view to be ready, attempt $attempts\n'); - } - - tester.printToConsole('List view did not become ready within expected time'); - } - - Future _verifyItemDisplay(dynamic item, DashboardViewModel dashboardViewModel) async { - // Execute the proper check depending on item type. - switch (item.runtimeType) { - case TransactionListItem: - final transactionItem = item as TransactionListItem; - tester.printToConsole(transactionItem.formattedTitle); - tester.printToConsole(transactionItem.formattedFiatAmount); - tester.printToConsole('\n'); - await _verifyTransactionListItemDisplay(transactionItem, dashboardViewModel); - break; - - case AnonpayTransactionListItem: - await _verifyAnonpayTransactionListItemDisplay(item as AnonpayTransactionListItem); - break; - - case TradeListItem: - await _verifyTradeListItemDisplay(item as TradeListItem); - break; - - case OrderListItem: - await _verifyOrderListItemDisplay(item as OrderListItem); - break; - - default: - tester.printToConsole('Unhandled item type: ${item.runtimeType}'); - } - } - - Future _verifyTransactionListItemDisplay( - TransactionListItem item, - DashboardViewModel dashboardViewModel, - ) async { - final keyId = - '${dashboardViewModel.type.name}_transaction_history_item_${item.transaction.id}_key'; - - if (!tester.any(find.byKey(ValueKey(keyId)))) { - tester.printToConsole( - 'Could not find transaction item with key: $keyId for transaction ${item.transaction.id}', - ); - return; - } - - if (item.hasTokens && item.assetOfTransaction == null) return; - - try { - //* ==============Confirm it has the right key for this item ======== - commonTestCases.hasValueKey(keyId); - - //* ======Confirm it displays the properly formatted amount========== - commonTestCases.findWidgetViaDescendant( - of: find.byKey(ValueKey(keyId)), - matching: find.text(item.formattedCryptoAmount), - ); - - //* ======Confirm it displays the properly formatted date============ - final formattedDate = DateFormat('HH:mm').format(item.transaction.date); - commonTestCases.findWidgetViaDescendant( - of: find.byKey(ValueKey(keyId)), - matching: find.text(formattedDate), - ); - - //* ======Confirm it displays the properly formatted fiat amount===== - final formattedFiatAmount = - dashboardViewModel.balanceViewModel.isFiatDisabled ? '' : item.formattedFiatAmount; - if (formattedFiatAmount.isNotEmpty) { - commonTestCases.findWidgetViaDescendant( - of: find.byKey(ValueKey(keyId)), - matching: find.text(formattedFiatAmount), - ); - } - - //* ======Confirm it displays the right image based on the transaction direction===== - final imageToUse = item.transaction.direction == TransactionDirection.incoming - ? 'assets/images/down_arrow.png' - : 'assets/images/up_arrow.png'; - - find.widgetWithImage(Container, AssetImage(imageToUse)); - } catch (e) { - tester.printToConsole('Error verifying transaction item ${item.transaction.id}: $e'); - } - } - - Future _verifyAnonpayTransactionListItemDisplay(AnonpayTransactionListItem item) async { - final keyId = 'anonpay_invoice_transaction_list_item_${item.transaction.invoiceId}_key'; - - //* ==============Confirm it has the right key for this item ======== - commonTestCases.hasValueKey(keyId); - - //* ==============Confirm it displays the correct provider ========================= - commonTestCases.hasText(item.transaction.provider); - - //* ===========Confirm it displays the properly formatted amount with currency ======== - final currency = item.transaction.fiatAmount != null - ? item.transaction.fiatEquiv ?? '' - : CryptoCurrency.fromFullName(item.transaction.coinTo).name.toUpperCase(); - - final amount = - item.transaction.fiatAmount?.toString() ?? (item.transaction.amountTo?.toString() ?? ''); - - final amountCurrencyText = amount + ' ' + currency; - - commonTestCases.hasText(amountCurrencyText); - - //* ======Confirm it displays the properly formatted date================= - final formattedDate = DateFormat('HH:mm').format(item.transaction.createdAt); - commonTestCases.hasText(formattedDate); - - //* ===============Confirm it displays the right image==================== - find.widgetWithImage(ClipRRect, AssetImage('assets/images/trocador.png')); - } - - Future _verifyTradeListItemDisplay(TradeListItem item) async { - final keyId = 'trade_list_item_${item.trade.id}_key'; - final from = item.trade.from?.toString() ?? ''; - final to = item.trade.to?.toString() ?? ''; - - //* ==============Confirm it has the right key for this item ======== - commonTestCases.hasValueKey(keyId); - - //* ==============Confirm it displays the correct provider ========================= - final conversionFlow = '$from → $to'; - - commonTestCases.hasText(conversionFlow); - - //* ===========Confirm it displays the properly formatted amount with its crypto tag ======== - - final amountCryptoText = item.tradeFormattedAmount + ' ' + from; - - commonTestCases.hasText(amountCryptoText); - - //* ======Confirm it displays the properly formatted date================= - final createdAtFormattedDate = - item.trade.createdAt != null ? DateFormat('HH:mm').format(item.trade.createdAt!) : null; - - if (createdAtFormattedDate != null) { - commonTestCases.hasText(createdAtFormattedDate); - } - - //* ===============Confirm it displays the right image==================== - commonTestCases.hasValueKey(item.trade.provider.image); - } - - Future _verifyOrderListItemDisplay(OrderListItem item) async { - final keyId = 'order_list_item_${item.order.id}_key'; - - //* ==============Confirm it has the right key for this item ======== - commonTestCases.hasValueKey(keyId); - - //* ==============Confirm it displays the correct provider ========================= - final orderFlow = '${item.order.from!} → ${item.order.to}'; - - commonTestCases.hasText(orderFlow); - - //* ===========Confirm it displays the properly formatted amount with its crypto tag ======== - - final amountCryptoText = item.orderFormattedAmount + ' ' + item.order.to!; - - commonTestCases.hasText(amountCryptoText); - - //* ======Confirm it displays the properly formatted date================= - final createdAtFormattedDate = DateFormat('HH:mm').format(item.order.createdAt); - - commonTestCases.hasText(createdAtFormattedDate); - } -} diff --git a/integration_test/robots/wallet_edit_page_robot.dart b/integration_test/robots/wallet_edit_page_robot.dart new file mode 100644 index 000000000..4faaaabe3 --- /dev/null +++ b/integration_test/robots/wallet_edit_page_robot.dart @@ -0,0 +1,50 @@ +import "package:cake_wallet/src/screens/wallet/wallet_edit_page.dart"; +import "package:flutter/material.dart"; +import "package:flutter_test/flutter_test.dart"; + +import "../core/base_robot.dart"; + +class WalletEditPageRobot extends BaseRobot { + WalletEditPageRobot(super.tester); + + @override + Future isDisplayed() async { + await isSpecificPage(); + } + + Future enterName(String name) async { + final input = find.byKey(const ValueKey("wallet_edit_page_name_input_key")); + + await pumpUntilFound(input); + + final field = find.descendant(of: input, matching: find.byType(EditableText)); + + await tester.enterText(field.first, name); + + await tester.pump(const Duration(milliseconds: 300)); + } + + Future save() async { + await tapByKey("wallet_edit_page_save_button_key"); + } + + Future tapDelete() async { + await tapByKey("wallet_edit_page_delete_button_key"); + } + + Future confirmDelete() async { + await tapByKey("wallet_edit_page_confirm_delete_button_key"); + } + + Future expectNameTakenRefusal() async { + await pumpUntilFound(find.byKey(const ValueKey("wallet_edit_page_name_taken_ok_button_key"))); + + hasType(); + } + + Future dismissNameTakenRefusal() async { + await tapByKey("wallet_edit_page_name_taken_ok_button_key"); + + await settle(); + } +} diff --git a/integration_test/robots/wallet_group_description_page_robot.dart b/integration_test/robots/wallet_group_description_page_robot.dart index 79c228f94..eff7e158d 100644 --- a/integration_test/robots/wallet_group_description_page_robot.dart +++ b/integration_test/robots/wallet_group_description_page_robot.dart @@ -1,39 +1,25 @@ -import 'package:cake_wallet/generated/i18n.dart'; -import 'package:cake_wallet/src/screens/new_wallet/wallet_group_description_page.dart'; -import 'package:flutter_test/flutter_test.dart'; +import "package:cake_wallet/src/screens/new_wallet/wallet_group_description_page.dart"; -import '../components/common_test_cases.dart'; +import "../core/base_robot.dart"; -class WalletGroupDescriptionPageRobot { - WalletGroupDescriptionPageRobot(this.tester) : commonTestCases = CommonTestCases(tester); +class WalletGroupDescriptionPageRobot extends BaseRobot { + WalletGroupDescriptionPageRobot(super.tester); - final WidgetTester tester; - final CommonTestCases commonTestCases; - - Future isWalletGroupDescriptionPage() async { - await commonTestCases.isSpecificPage(); - await commonTestCases.takeScreenshots('wallet_group_description_page'); + @override + Future isDisplayed() async { + await isSpecificPage(); } - void hasTitle() { - commonTestCases.hasText(S.current.wallet_group); - } - - bool hasNewSingleSeedButton() { - return commonTestCases.isKeyPresent('wallet_group_description_page_create_new_seed_button_key'); - } + bool hasNewSingleSeedButton() => + isKeyPresent("wallet_group_description_page_create_new_seed_button_key"); Future navigateToCreateNewSeedPage() async { if (hasNewSingleSeedButton()) { - await commonTestCases.tapItemByKey( - 'wallet_group_description_page_create_new_seed_button_key', - ); + await tapByKey("wallet_group_description_page_create_new_seed_button_key"); } } Future navigateToChooseWalletGroup() async { - await commonTestCases.tapItemByKey( - 'wallet_group_description_page_choose_wallet_group_button_key', - ); + await tapByKey("wallet_group_description_page_choose_wallet_group_button_key"); } } diff --git a/integration_test/robots/wallet_group_existing_seed_page_robot.dart b/integration_test/robots/wallet_group_existing_seed_page_robot.dart new file mode 100644 index 000000000..4d28edfb4 --- /dev/null +++ b/integration_test/robots/wallet_group_existing_seed_page_robot.dart @@ -0,0 +1,16 @@ +import "package:cake_wallet/src/screens/new_wallet/wallet_group_existing_seed_description_page.dart"; + +import "../core/base_robot.dart"; + +class WalletGroupExistingSeedPageRobot extends BaseRobot { + WalletGroupExistingSeedPageRobot(super.tester); + + @override + Future isDisplayed() async { + await isSpecificPage(); + } + + Future openWallet() async { + await tapByKey("wallet_group_existing_seed_description_page_open_wallet_button_key"); + } +} diff --git a/integration_test/robots/wallet_groups_display_page_robot.dart b/integration_test/robots/wallet_groups_display_page_robot.dart new file mode 100644 index 000000000..d87064863 --- /dev/null +++ b/integration_test/robots/wallet_groups_display_page_robot.dart @@ -0,0 +1,21 @@ +import "package:cake_wallet/src/screens/new_wallet/wallet_group_display_page.dart"; +import "package:flutter_test/flutter_test.dart"; + +import "../core/base_robot.dart"; + +class WalletGroupsDisplayPageRobot extends BaseRobot { + WalletGroupsDisplayPageRobot(super.tester); + + @override + Future isDisplayed() async { + await isSpecificPage(); + } + + Future selectWallet(String name) async { + await tapWhenVisible(find.text(name)); + } + + Future continueWithSelectedSeed() async { + await tapByKey("wallet_group_display_page_next_button_key"); + } +} diff --git a/integration_test/robots/wallet_keys_robot.dart b/integration_test/robots/wallet_keys_robot.dart index 6ec9f9df7..35f77fc27 100644 --- a/integration_test/robots/wallet_keys_robot.dart +++ b/integration_test/robots/wallet_keys_robot.dart @@ -1,48 +1,38 @@ -import 'package:cake_wallet/generated/i18n.dart'; -import 'package:cake_wallet/reactions/wallet_connect.dart'; -import 'package:cake_wallet/src/screens/wallet_keys/wallet_keys_page.dart'; -import 'package:cake_wallet/store/app_store.dart'; -import 'package:cw_core/monero_wallet_keys.dart'; -import 'package:cw_core/wallet_type.dart'; -import 'package:cw_monero/monero_wallet.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:polyseed/polyseed.dart'; +import "package:cake_wallet/reactions/wallet_connect.dart"; +import "package:cake_wallet/src/screens/wallet_keys/wallet_keys_page.dart"; +import "package:cake_wallet/store/app_store.dart"; +import "package:cw_core/monero_wallet_keys.dart"; +import "package:cw_core/wallet_type.dart"; +import "package:cw_monero/monero_wallet.dart"; +import "package:flutter_test/flutter_test.dart"; +import "package:polyseed/polyseed.dart"; -import '../components/common_test_cases.dart'; +import "../core/base_robot.dart"; -class WalletKeysAndSeedPageRobot { - WalletKeysAndSeedPageRobot(this.tester) : commonTestCases = CommonTestCases(tester); +class WalletKeysAndSeedPageRobot extends BaseRobot { + WalletKeysAndSeedPageRobot(super.tester); - final WidgetTester tester; - final CommonTestCases commonTestCases; - - Future isWalletKeysAndSeedPage() async { - await commonTestCases.isSpecificPage(); - await commonTestCases.takeScreenshots('wallet_keys_page'); + @override + Future isDisplayed() async { + await isSpecificPage(); } - void hasTitle() { - final walletKeysPage = tester.widget(find.byType(WalletKeysPage)); - final walletKeysViewModel = walletKeysPage.walletKeysViewModel; - commonTestCases.hasText(walletKeysViewModel.title); - } - - void hasShareWarning() { - commonTestCases.hasText(S.current.do_not_share_warning_text.toUpperCase()); - } + int _verifiedCredentials = 0; Future confirmWalletCredentials(WalletType walletType) async { + _verifiedCredentials = 0; + final walletKeysPage = tester.widget(find.byType(WalletKeysPage)); final walletKeysViewModel = walletKeysPage.walletKeysViewModel; final appStore = walletKeysViewModel.appStore; final walletName = walletType.name; - bool hasSeed = appStore.wallet!.seed != null; - bool hasHexSeed = appStore.wallet!.hexSeed != null; - bool hasPrivateKey = appStore.wallet!.privateKey != null; + final hasSeed = appStore.wallet!.seed != null; + final hasHexSeed = appStore.wallet!.hexSeed != null; + final hasPrivateKey = appStore.wallet!.privateKey != null; if (walletType == WalletType.monero) { - final moneroWallet = appStore.wallet as MoneroWalletBase; + final moneroWallet = appStore.wallet! as MoneroWalletBase; final lang = PolyseedLang.getByPhrase(moneroWallet.seed); final legacySeed = moneroWallet.seedLegacy(lang.nameEnglish); @@ -57,51 +47,89 @@ class WalletKeysAndSeedPageRobot { if (walletType == WalletType.bitcoin || walletType == WalletType.litecoin || walletType == WalletType.bitcoinCash) { - final seedWords = appStore.wallet!.seed!.split(" "); - for (var seedWord in seedWords) { - commonTestCases.hasTextAtLestOnce(seedWord); - } - tester.printToConsole('$walletName wallet has seeds properly displayed'); + _showsSeedWords(appStore.wallet!.seed!); + _verifiedCredentials++; + tester.printToConsole("$walletName wallet has seeds properly displayed"); } if (isEVMCompatibleChain(walletType) || walletType == WalletType.solana || walletType == WalletType.tron) { if (hasSeed) { - final seedWords = appStore.wallet!.seed!.split(" "); - for (var seedWord in seedWords) { - commonTestCases.hasTextAtLestOnce(seedWord); - } - tester.printToConsole('$walletName wallet has seeds properly displayed'); + _showsSeedWords(appStore.wallet!.seed!); + _verifiedCredentials++; + tester.printToConsole("$walletName wallet has seeds properly displayed"); } if (hasPrivateKey) { - await commonTestCases.tapItemByKey('wallet_keys_page_keys'); - commonTestCases.hasText(appStore.wallet!.privateKey!); - tester.printToConsole('$walletName wallet has private key properly displayed'); + await _openKeysTab(); + _showsCredentialOnce(appStore.wallet!.privateKey!, "private key"); + _verifiedCredentials++; + tester.printToConsole("$walletName wallet has private key properly displayed"); } } if (walletType == WalletType.nano || walletType == WalletType.banano) { if (hasSeed) { - final seedWords = appStore.wallet!.seed!.split(" "); - for (var seedWord in seedWords) { - commonTestCases.hasTextAtLestOnce(seedWord); - } - tester.printToConsole('$walletName wallet has seeds properly displayed'); + _showsSeedWords(appStore.wallet!.seed!); + _verifiedCredentials++; + tester.printToConsole("$walletName wallet has seeds properly displayed"); } if (hasHexSeed) { - await commonTestCases.tapItemByKey('wallet_keys_page_keys'); - commonTestCases.hasText(appStore.wallet!.hexSeed!); - tester.printToConsole('$walletName wallet has hexSeed properly displayed'); + await _openKeysTab(); + _showsCredentialOnce(appStore.wallet!.hexSeed!, "hex seed"); + _verifiedCredentials++; + tester.printToConsole("$walletName wallet has hexSeed properly displayed"); } if (hasPrivateKey) { - await commonTestCases.tapItemByKey('wallet_keys_page_keys'); - commonTestCases.hasText(appStore.wallet!.privateKey!); - tester.printToConsole('$walletName wallet has private key properly displayed'); + await _openKeysTab(); + _showsCredentialOnce(appStore.wallet!.privateKey!, "private key"); + _verifiedCredentials++; + tester.printToConsole("$walletName wallet has private key properly displayed"); } } - await commonTestCases.defaultSleepTime(seconds: 5); + _expectSomethingWasVerified(walletType); + } + + void _expectSomethingWasVerified(WalletType walletType) { + expect( + _verifiedCredentials, + greaterThan(0), + reason: "Nothing was verified for ${walletType.name}, this suite covers no credential " + "for that type so it cannot tell a working keys page from a broken one", + ); + } + + Future _openKeysTab() async { + await tapByKey("wallet_keys_page_keys"); + + await settle(); + } + + void _showsCredential(String value, String label) { + expect( + tester.any(find.text(value, skipOffstage: false)), + true, + reason: "The keys page did not show the wallet's $label", + ); + } + + void _showsCredentialOnce(String value, String label) { + final rows = tester.widgetList(find.text(value, skipOffstage: false)).length; + + expect( + rows, + 1, + reason: "The keys page showed $rows rows for the wallet's $label, expected one", + ); + } + + void _showsSeedWords(String seed) { + final words = seed.split(" "); + + for (var index = 0; index < words.length; index++) { + _showsCredential(words[index], "seed word ${index + 1}"); + } } Future _confirmMoneroWalletCredentials( @@ -111,68 +139,42 @@ class WalletKeysAndSeedPageRobot { String legacySeed, ) async { final keys = appStore.wallet!.keys as MoneroWalletKeys; - - final hasPublicSpendKey = commonTestCases.isKeyPresent( - '${walletName}_wallet_public_spend_key_item_key', - ); - final hasPrivateSpendKey = commonTestCases.isKeyPresent( - '${walletName}_wallet_private_spend_key_item_key', - ); - final hasPublicViewKey = commonTestCases.isKeyPresent( - '${walletName}_wallet_public_view_key_item_key', - ); - final hasPrivateViewKey = commonTestCases.isKeyPresent( - '${walletName}_wallet_private_view_key_item_key', - ); final hasSeeds = seed.isNotEmpty; final hasSeedLegacy = Polyseed.isValidSeed(seed); - if (hasPublicSpendKey) { - await commonTestCases.tapItemByKey('wallet_keys_page_keys'); - commonTestCases.hasText(keys.publicSpendKey); - tester.printToConsole('$walletName wallet has public spend key properly displayed'); - } - if (hasPrivateSpendKey) { - await commonTestCases.tapItemByKey('wallet_keys_page_keys'); - commonTestCases.hasText(keys.privateSpendKey); - tester.printToConsole('$walletName wallet has private spend key properly displayed'); - } - if (hasPublicViewKey) { - await commonTestCases.tapItemByKey('wallet_keys_page_keys'); - commonTestCases.hasText(keys.publicViewKey); - tester.printToConsole('$walletName wallet has public view key properly displayed'); - } - if (hasPrivateViewKey) { - await commonTestCases.tapItemByKey('wallet_keys_page_keys'); - commonTestCases.hasText(keys.privateViewKey); - tester.printToConsole('$walletName wallet has private view key properly displayed'); - } + await _openKeysTab(); + + _showsCredentialOnce(keys.publicSpendKey, "public spend key"); + _verifiedCredentials++; + tester.printToConsole("$walletName wallet has public spend key properly displayed"); + + _showsCredentialOnce(keys.privateSpendKey, "private spend key"); + _verifiedCredentials++; + tester.printToConsole("$walletName wallet has private spend key properly displayed"); + + _showsCredentialOnce(keys.publicViewKey, "public view key"); + _verifiedCredentials++; + tester.printToConsole("$walletName wallet has public view key properly displayed"); + + _showsCredentialOnce(keys.privateViewKey, "private view key"); + _verifiedCredentials++; + tester.printToConsole("$walletName wallet has private view key properly displayed"); + if (hasSeeds) { - await commonTestCases.tapItemByKey('wallet_keys_page_seed'); - final seedWords = seed.split(" "); - for (var seedWord in seedWords) { - commonTestCases.hasTextAtLestOnce(seedWord); - } - tester.printToConsole('$walletName wallet has seeds properly displayed'); + await tapByKey("wallet_keys_page_seed"); + await settle(); + + _showsSeedWords(seed); + _verifiedCredentials++; + tester.printToConsole("$walletName wallet has seeds properly displayed"); } if (hasSeedLegacy) { - await commonTestCases.tapItemByKey('wallet_keys_page_seed_legacy'); - final seedWords = legacySeed.split(" "); - for (var seedWord in seedWords) { - commonTestCases.hasTextAtLestOnce(seedWord); - } - tester.printToConsole('$walletName wallet has legacy seeds properly displayed'); + await tapByKey("wallet_keys_page_seed_legacy"); + await settle(); + + _showsSeedWords(legacySeed); + _verifiedCredentials++; + tester.printToConsole("$walletName wallet has legacy seeds properly displayed"); } } - - Future backToDashboard() async { - tester.printToConsole('Going back to dashboard from credentials page'); - await tester.pumpAndSettle(Duration(milliseconds: 1000)); - - await commonTestCases.goBack(); - await tester.pumpAndSettle(Duration(milliseconds: 1000)); - - await commonTestCases.goBack(); - await tester.pumpAndSettle(Duration(milliseconds: 2000)); - } } diff --git a/integration_test/robots/wallet_list_page_robot.dart b/integration_test/robots/wallet_list_page_robot.dart index 3ce6f0938..598f0b08e 100644 --- a/integration_test/robots/wallet_list_page_robot.dart +++ b/integration_test/robots/wallet_list_page_robot.dart @@ -1,28 +1,48 @@ -import 'package:cake_wallet/generated/i18n.dart'; -import 'package:flutter_test/flutter_test.dart'; +import "package:cake_wallet/src/screens/wallet_list/wallet_list_page.dart"; +import "package:flutter/material.dart"; +import "package:flutter_test/flutter_test.dart"; -import '../components/common_test_cases.dart'; +import "../core/base_robot.dart"; -class WalletListPageRobot { - WalletListPageRobot(this.tester) : commonTestCases = CommonTestCases(tester); +class WalletListPageRobot extends BaseRobot { + WalletListPageRobot(super.tester); - final WidgetTester tester; - late CommonTestCases commonTestCases; + @override + Future isDisplayed() async { + final shown = await pumpUntil(_isShowing); - Future isWalletListPage() async { - await commonTestCases.isSpecificPage(); - await commonTestCases.takeScreenshots('wallet_list_page'); + expect(shown, true, reason: "The wallets tab never came to the front"); } - void displaysCorrectTitle() { - commonTestCases.hasText(S.current.wallets); + Future openEditFor(String walletName) async { + await tapByKey("wallet_list_edit_${walletName}_button_key"); } + bool hasWallet(String walletName) => tester.any( + find.descendant( + of: find.byType(WalletListPage), + matching: find.text(walletName), + ), + ); + Future navigateToCreateNewWalletPage() async { - await commonTestCases.tapItemByKey('wallet_list_page_create_new_wallet_button_key'); + await tapByKey("wallet_list_page_create_new_wallet_button_key"); } Future navigateToRestoreWalletOptionsPage() async { - await commonTestCases.tapItemByKey('wallet_list_page_restore_wallet_button_key'); + await tapByKey("wallet_list_page_restore_wallet_button_key"); + } + + bool _isShowing() { + final stackFinder = find.byType(IndexedStack); + + if (!tester.any(stackFinder)) { + return false; + } + + final stack = tester.widget(stackFinder.first); + final index = stack.children.indexWhere((child) => child is WalletListPage); + + return index >= 0 && stack.index == index; } } diff --git a/integration_test/robots/wallet_seed_page_robot.dart b/integration_test/robots/wallet_seed_page_robot.dart index 7bcbe6afe..4697f3624 100644 --- a/integration_test/robots/wallet_seed_page_robot.dart +++ b/integration_test/robots/wallet_seed_page_robot.dart @@ -1,38 +1,19 @@ -import 'package:cake_wallet/generated/i18n.dart'; -import 'package:cake_wallet/src/screens/seed/wallet_seed_page.dart'; -import 'package:flutter_test/flutter_test.dart'; +import "package:cake_wallet/generated/i18n.dart"; +import "package:cake_wallet/src/screens/seed/wallet_seed_page.dart"; +import "package:flutter_test/flutter_test.dart"; -import '../components/common_test_cases.dart'; +import "../core/base_robot.dart"; -class WalletSeedPageRobot { - WalletSeedPageRobot(this.tester) : commonTestCases = CommonTestCases(tester); +class WalletSeedPageRobot extends BaseRobot { + WalletSeedPageRobot(super.tester); - final WidgetTester tester; - late CommonTestCases commonTestCases; - - Future isWalletSeedPage() async { - await commonTestCases.isSpecificPage(); - await commonTestCases.takeScreenshots('wallet_seed_page'); + @override + Future isDisplayed() async { + await isSpecificPage(); } Future onVerifySeedButtonPressed() async { - await commonTestCases.tapItemByKey('wallet_seed_page_verify_seed_button_key'); - await commonTestCases.defaultSleepTime(); - } - - Future onSaveSeedButtonPressed() async { - await commonTestCases.tapItemByKey('wallet_seed_page_save_seeds_button_key'); - await commonTestCases.defaultSleepTime(); - } - - Future onConfirmButtonOnSeedAlertDialogPressed() async { - await commonTestCases.tapItemByKey('wallet_seed_page_seed_alert_confirm_button_key'); - await commonTestCases.defaultSleepTime(); - } - - Future onBackButtonOnSeedAlertDialogPressed() async { - await commonTestCases.tapItemByKey('wallet_seed_page_seed_alert_back_button_key'); - await commonTestCases.defaultSleepTime(); + await tapByKey("wallet_seed_page_verify_seed_button_key"); } void confirmWalletDetailsDisplayCorrectly() { @@ -42,23 +23,18 @@ class WalletSeedPageRobot { final walletName = walletSeedViewModel.name; final walletSeeds = walletSeedViewModel.seedSplit; - commonTestCases.hasText(walletName); - for (var seed in walletSeeds) { - commonTestCases.hasTextAtLestOnce(seed); + hasText(walletName); + + for (var index = 0; index < walletSeeds.length; index++) { + expect( + tester.any(find.text(walletSeeds[index])), + true, + reason: "The seed page did not show seed word ${index + 1}", + ); } } void confirmWalletSeedReminderDisplays() { - commonTestCases.hasText(S.current.cake_seeds_save_disclaimer); - } - - Future onSaveSeedsButtonPressed() async { - await commonTestCases.tapItemByKey('wallet_seed_page_save_seeds_button_key'); - await commonTestCases.defaultSleepTime(); - } - - Future onCopySeedsButtonPressed() async { - await commonTestCases.tapItemByKey('wallet_seed_page_copy_seeds_button_key'); - await commonTestCases.defaultSleepTime(); + hasText(S.current.cake_seeds_save_disclaimer); } } diff --git a/integration_test/robots/welcome_page_robot.dart b/integration_test/robots/welcome_page_robot.dart index 56a54f38e..59428d54a 100644 --- a/integration_test/robots/welcome_page_robot.dart +++ b/integration_test/robots/welcome_page_robot.dart @@ -1,50 +1,28 @@ -import 'package:cake_wallet/src/screens/welcome/welcome_page.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; +import "package:cake_wallet/src/screens/welcome/welcome_page.dart"; +import "package:flutter_test/flutter_test.dart"; -import '../components/common_test_cases.dart'; +import "../core/base_robot.dart"; -class WelcomePageRobot { - WelcomePageRobot(this.tester) : commonTestCases = CommonTestCases(tester); +class WelcomePageRobot extends BaseRobot { + WelcomePageRobot(super.tester); - final WidgetTester tester; - late CommonTestCases commonTestCases; - - Future isWelcomePage() async { - await commonTestCases.isSpecificPage(); - await commonTestCases.takeScreenshots('welcome_page'); - } - - void confirmActionButtonsDisplay() { - final createNewWalletButton = find.byKey(ValueKey('welcome_page_create_new_wallet_button_key')); - - final restoreWalletButton = find.byKey(ValueKey('welcome_page_restore_wallet_button_key')); - - expect(createNewWalletButton, findsOneWidget); - expect(restoreWalletButton, findsOneWidget); + @override + Future isDisplayed() async { + await isSpecificPage(); } Future navigateToCreateNewWalletPage() async { - await commonTestCases.tapItemByKey('welcome_page_create_new_wallet_button_key'); - await commonTestCases.defaultSleepTime(); + await tapByKey("welcome_page_create_new_wallet_button_key"); } - bool hasNewSingleSeedButton() { - return commonTestCases.isKeyPresent('wallet_group_description_page_create_new_seed_button_key'); - } + bool hasNewSingleSeedButton() => + isKeyPresent("wallet_group_description_page_create_new_seed_button_key"); Future tapNewSingleSeed() async { - await commonTestCases.tapItemByKey('wallet_group_description_page_create_new_seed_button_key'); - await commonTestCases.defaultSleepTime(); + await tapByKey("wallet_group_description_page_create_new_seed_button_key"); } Future navigateToRestoreWalletPage() async { - await commonTestCases.tapItemByKey('welcome_page_restore_wallet_button_key'); - await commonTestCases.defaultSleepTime(); - } - - Future backAndVerify() async { - await commonTestCases.goBack(); - await isWelcomePage(); + await tapByKey("welcome_page_restore_wallet_button_key"); } } diff --git a/integration_test/suites/tier0/address_book_test.dart b/integration_test/suites/tier0/address_book_test.dart new file mode 100644 index 000000000..b45841384 --- /dev/null +++ b/integration_test/suites/tier0/address_book_test.dart @@ -0,0 +1,46 @@ +import "package:cw_core/wallet_type.dart"; +import "package:integration_test/integration_test.dart"; + +import "../../core/app_launcher.dart"; +import "../../core/test_wallets.dart"; +import "../../flows/onboarding_flows.dart"; +import "../../robots/contact_robot.dart"; +import "../../robots/home_page_robot.dart"; +import "../../robots/new_dashboard_robot.dart"; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + integrationTest("A saved contact keeps the address it was given", (tester) async { + final appLauncher = AppLauncher(tester); + final onboardingFlows = OnboardingFlows(tester); + final dashboardRobot = NewDashboardRobot(tester); + final homePageRobot = HomePageRobot(tester); + final contactRobot = ContactRobot(tester); + + const contactName = "Integration Test Contact"; + final contactAddress = TestWallets.receiveAddressFor(WalletType.solana); + + await appLauncher.launchApp(testKey: "address_book_test_app_key"); + + await onboardingFlows.createFirstWallet(WalletType.solana); + + await dashboardRobot.isDisplayed(); + await homePageRobot.isDisplayed(); + + await dashboardRobot.openContactsTab(); + await contactRobot.isDisplayed(); + + await contactRobot.openAddContact(); + + await contactRobot.enterName(contactName); + await contactRobot.chooseCurrency("sol"); + await contactRobot.enterAddress(contactAddress); + + await contactRobot.save(); + + await contactRobot.isDisplayed(); + + contactRobot.expectContactSaved(contactName, contactAddress); + }); +} diff --git a/integration_test/suites/tier0/change_pin_test.dart b/integration_test/suites/tier0/change_pin_test.dart new file mode 100644 index 000000000..fb04b3612 --- /dev/null +++ b/integration_test/suites/tier0/change_pin_test.dart @@ -0,0 +1,73 @@ +import "package:cake_wallet/routes.dart"; +import "package:cake_wallet/src/screens/wallet_keys/wallet_keys_page.dart"; +import "package:cw_core/wallet_type.dart"; +import "package:flutter_test/flutter_test.dart"; +import "package:integration_test/integration_test.dart"; + +import "../../core/app_launcher.dart"; +import "../../core/test_config.dart"; +import "../../flows/auth_flows.dart"; +import "../../flows/onboarding_flows.dart"; +import "../../robots/home_page_robot.dart"; +import "../../robots/new_dashboard_robot.dart"; +import "../../robots/new_settings_page_robot.dart"; +import "../../robots/security_backup_page_robot.dart"; +import "../../robots/setup_pin_code_robot.dart"; +import "../../robots/show_keys_disclaimer_page_robot.dart"; +import "../../robots/wallet_keys_robot.dart"; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + integrationTest("Changing the pin replaces the one that unlocks the wallet", (tester) async { + final appLauncher = AppLauncher(tester); + final onboardingFlows = OnboardingFlows(tester); + final authFlows = AuthFlows(tester); + final dashboardRobot = NewDashboardRobot(tester); + final homePageRobot = HomePageRobot(tester); + final settingsRobot = NewSettingsPageRobot(tester); + final securityRobot = SecurityBackupPageRobot(tester); + final setupPinRobot = SetupPinCodeRobot(tester); + final walletKeysRobot = WalletKeysAndSeedPageRobot(tester); + final disclaimerRobot = ShowKeysDisclaimerPageRobot(tester); + + final newPin = [1, 2, 3, 4]; + + expect(newPin, isNot(TestConfig.pin), reason: "The new pin has to differ from the old one"); + + await appLauncher.launchApp(testKey: "change_pin_test_app_key"); + + await onboardingFlows.createFirstWallet(WalletType.solana); + + await dashboardRobot.isDisplayed(); + await homePageRobot.isDisplayed(); + + await homePageRobot.openSettingsSheet(); + await settingsRobot.isDisplayed(); + + await settingsRobot.openRow(Routes.securityBackupPage); + await securityRobot.isDisplayed(); + + await securityRobot.openChangePin(); + await authFlows.authenticateWithPin(); + + await setupPinRobot.isDisplayed(); + await setupPinRobot.enterPinCode(newPin); + await setupPinRobot.enterPinCode(newPin); + await setupPinRobot.tapSuccessButton(); + + await securityRobot.isDisplayed(); + + await securityRobot.goBack(); + await settingsRobot.isDisplayed(); + + await settingsRobot.openRow(Routes.showKeysDisclaimer); + await disclaimerRobot.isDisplayed(); + await disclaimerRobot.tapShowKeys(); + await authFlows.authenticateWithPin(pin: newPin); + + await walletKeysRobot.isDisplayed(); + + expect(find.byType(WalletKeysPage), findsOneWidget); + }); +} diff --git a/integration_test/suites/tier0/duplicate_wallet_name_test.dart b/integration_test/suites/tier0/duplicate_wallet_name_test.dart new file mode 100644 index 000000000..0e194ee55 --- /dev/null +++ b/integration_test/suites/tier0/duplicate_wallet_name_test.dart @@ -0,0 +1,42 @@ +import "package:cake_wallet/di.dart"; +import "package:cake_wallet/store/app_store.dart"; +import "package:cw_core/wallet_type.dart"; +import "package:integration_test/integration_test.dart"; + +import "../../core/app_launcher.dart"; +import "../../flows/onboarding_flows.dart"; +import "../../robots/home_page_robot.dart"; +import "../../robots/new_dashboard_robot.dart"; +import "../../robots/new_wallet_page_robot.dart"; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + integrationTest("A wallet name already in use is refused", (tester) async { + final appLauncher = AppLauncher(tester); + final onboardingFlows = OnboardingFlows(tester); + final dashboardRobot = NewDashboardRobot(tester); + final homePageRobot = HomePageRobot(tester); + final newWalletPageRobot = NewWalletPageRobot(tester); + + const walletType = WalletType.solana; + + await appLauncher.launchApp(testKey: "duplicate_wallet_name_test_app_key"); + + await onboardingFlows.createFirstWallet(walletType); + + await dashboardRobot.isDisplayed(); + await homePageRobot.isDisplayed(); + + final existingName = getIt.get().wallet!.name; + + await dashboardRobot.openWalletsTab(); + + await onboardingFlows.startCreatingWalletFromWalletList(walletType); + + await newWalletPageRobot.enterWalletName(existingName); + await newWalletPageRobot.onNextButtonPressed(); + + await newWalletPageRobot.expectNameRejected(); + }); +} diff --git a/integration_test/suites/tier0/fiat_currency_test.dart b/integration_test/suites/tier0/fiat_currency_test.dart new file mode 100644 index 000000000..92e4a0940 --- /dev/null +++ b/integration_test/suites/tier0/fiat_currency_test.dart @@ -0,0 +1,107 @@ +import "package:cake_wallet/di.dart"; +import "package:cake_wallet/entities/fiat_api_mode.dart"; +import "package:cake_wallet/entities/fiat_currency.dart"; +import "package:cake_wallet/routes.dart"; +import "package:cake_wallet/store/settings_store.dart"; +import "package:cw_core/wallet_type.dart"; +import "package:flutter_test/flutter_test.dart"; +import "package:integration_test/integration_test.dart"; + +import "../../core/app_launcher.dart"; +import "../../flows/onboarding_flows.dart"; +import "../../robots/connection_sync_page_robot.dart"; +import "../../robots/display_settings_page_robot.dart"; +import "../../robots/home_page_robot.dart"; +import "../../robots/new_dashboard_robot.dart"; +import "../../robots/new_settings_page_robot.dart"; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + integrationTest("Turning the fiat api off hides the currency setting, turning it on returns it", + (tester) async { + final appLauncher = AppLauncher(tester); + final onboardingFlows = OnboardingFlows(tester); + final dashboardRobot = NewDashboardRobot(tester); + final homePageRobot = HomePageRobot(tester); + final settingsRobot = NewSettingsPageRobot(tester); + final connectionRobot = ConnectionSyncPageRobot(tester); + final displayRobot = DisplaySettingsPageRobot(tester); + + const wanted = FiatCurrency.eur; + + await appLauncher.launchApp(testKey: "fiat_currency_test_app_key"); + + await onboardingFlows.createFirstWallet(WalletType.solana); + + await dashboardRobot.isDisplayed(); + await homePageRobot.isDisplayed(); + + final settingsStore = getIt.get(); + + expect( + settingsStore.fiatCurrency, + isNot(wanted), + reason: "The wallet already starts on $wanted, so changing to it would prove nothing", + ); + + await homePageRobot.openSettingsSheet(); + await settingsRobot.isDisplayed(); + + if (settingsStore.fiatApiMode != FiatApiMode.disabled) { + await settingsRobot.openRow(Routes.connectionSync); + await connectionRobot.isDisplayed(); + + await connectionRobot.setFiatApiMode(FiatApiMode.disabled); + connectionRobot.hasFiatApiMode(FiatApiMode.disabled); + + expect(settingsStore.fiatApiMode, FiatApiMode.disabled); + + await connectionRobot.goBack(); + await settingsRobot.isDisplayed(); + } + + await settingsRobot.openRow(Routes.displaySettingsPage); + await displayRobot.isDisplayed(); + + expect( + displayRobot.hasFiatCurrencyRow(), + false, + reason: "The currency setting is still showing while the fiat api is off", + ); + + await displayRobot.goBack(); + await settingsRobot.isDisplayed(); + + await settingsRobot.openRow(Routes.connectionSync); + await connectionRobot.isDisplayed(); + + await connectionRobot.setFiatApiMode(FiatApiMode.enabled); + connectionRobot.hasFiatApiMode(FiatApiMode.enabled); + + expect(settingsStore.fiatApiMode, FiatApiMode.enabled); + + await connectionRobot.goBack(); + await settingsRobot.isDisplayed(); + + await settingsRobot.openRow(Routes.displaySettingsPage); + await displayRobot.isDisplayed(); + + expect( + displayRobot.hasFiatCurrencyRow(), + true, + reason: "The currency setting did not come back after the fiat api was turned on", + ); + + await displayRobot.openFiatCurrencyPicker(); + await displayRobot.chooseFiatCurrency(wanted); + + displayRobot.expectFiatCurrencyShown(wanted); + + expect( + settingsStore.fiatCurrency, + wanted, + reason: "The row shows ${wanted.title} but the setting behind it did not change", + ); + }); +} diff --git a/integration_test/suites/tier0/invalid_seed_test.dart b/integration_test/suites/tier0/invalid_seed_test.dart new file mode 100644 index 000000000..63ac5b659 --- /dev/null +++ b/integration_test/suites/tier0/invalid_seed_test.dart @@ -0,0 +1,51 @@ +import "package:cake_wallet/di.dart"; +import "package:cake_wallet/store/app_store.dart"; +import "package:cw_core/wallet_type.dart"; +import "package:flutter_test/flutter_test.dart"; +import "package:integration_test/integration_test.dart"; + +import "../../core/app_launcher.dart"; +import "../../core/test_wallets.dart"; +import "../../flows/onboarding_flows.dart"; +import "../../robots/home_page_robot.dart"; +import "../../robots/new_dashboard_robot.dart"; +import "../../robots/restore_from_seed_or_key_robot.dart"; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + integrationTest("A seed the wallet cannot parse restores nothing", (tester) async { + final appLauncher = AppLauncher(tester); + final onboardingFlows = OnboardingFlows(tester); + final restoreRobot = RestoreFromSeedOrKeysPageRobot(tester); + final dashboardRobot = NewDashboardRobot(tester); + final homePageRobot = HomePageRobot(tester); + + const walletType = WalletType.solana; + + await appLauncher.launchApp(testKey: "invalid_seed_test_app_key"); + + await onboardingFlows.startRestoringFirstWallet(walletType); + + // Twelve words that are not in the wordlist. Restoring from this would either fail + // outright or, worse it derives some other wallet the user has no keys for. + await restoreRobot.enterSeedPhraseForWalletRestore( + "alpha bravo charlie delta echo foxtrot golf hotel india juliet kilo lima", + ); + + await restoreRobot.onRestoreWalletButtonPressed(); + + await restoreRobot.expectRestoreRefused(); + + // The same form with the real seed goes through. Without this the test above would pass + // just as happily if the form were broken and restored nothing for any seed at all. + await restoreRobot.selectWalletNameFromAvailableOptions(); + await restoreRobot.enterSeedPhraseForWalletRestore(TestWallets.seedFor(walletType)); + await restoreRobot.onRestoreWalletButtonPressed(); + + await dashboardRobot.isDisplayed(); + await homePageRobot.isDisplayed(); + + expect(getIt.get().wallet?.type, walletType); + }); +} diff --git a/integration_test/suites/tier0/language_test.dart b/integration_test/suites/tier0/language_test.dart new file mode 100644 index 000000000..c38783988 --- /dev/null +++ b/integration_test/suites/tier0/language_test.dart @@ -0,0 +1,46 @@ +import "package:cake_wallet/routes.dart"; +import "package:cw_core/wallet_type.dart"; +import "package:integration_test/integration_test.dart"; + +import "../../core/app_launcher.dart"; +import "../../flows/onboarding_flows.dart"; +import "../../robots/display_settings_page_robot.dart"; +import "../../robots/home_page_robot.dart"; +import "../../robots/new_dashboard_robot.dart"; +import "../../robots/new_settings_page_robot.dart"; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + integrationTest("Changing the language changes what the settings screen shows", (tester) async { + final appLauncher = AppLauncher(tester); + final onboardingFlows = OnboardingFlows(tester); + final dashboardRobot = NewDashboardRobot(tester); + final homePageRobot = HomePageRobot(tester); + final settingsRobot = NewSettingsPageRobot(tester); + final displayRobot = DisplaySettingsPageRobot(tester); + + // Not the default, so seeing it afterwards means the change took rather than the screen + // having always said so. + const languageCode = "de"; + const languageName = "Deutsch (German)"; + + await appLauncher.launchApp(testKey: "language_test_app_key"); + + await onboardingFlows.createFirstWallet(WalletType.solana); + + await dashboardRobot.isDisplayed(); + await homePageRobot.isDisplayed(); + + await homePageRobot.openSettingsSheet(); + await settingsRobot.isDisplayed(); + + await settingsRobot.openRow(Routes.displaySettingsPage); + await displayRobot.isDisplayed(); + + await displayRobot.openLanguagePicker(); + await displayRobot.chooseLanguage(languageCode); + + displayRobot.expectLanguageShown(languageName); + }); +} diff --git a/integration_test/suites/tier0/onboarding_create_test.dart b/integration_test/suites/tier0/onboarding_create_test.dart new file mode 100644 index 000000000..7dc1886b1 --- /dev/null +++ b/integration_test/suites/tier0/onboarding_create_test.dart @@ -0,0 +1,53 @@ +import "package:cake_wallet/di.dart"; +import "package:cake_wallet/store/app_store.dart"; +import "package:flutter_test/flutter_test.dart"; +import "package:integration_test/integration_test.dart"; + +import "../../core/app_launcher.dart"; +import "../../core/test_config.dart"; +import "../../flows/onboarding_flows.dart"; +import "../../robots/home_page_robot.dart"; +import "../../robots/new_dashboard_robot.dart"; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + integrationTest("Create a wallet for each configured wallet type", (tester) async { + final appLauncher = AppLauncher(tester); + final onboardingFlows = OnboardingFlows(tester); + final dashboardRobot = NewDashboardRobot(tester); + final homePageRobot = HomePageRobot(tester); + + await appLauncher.launchApp(testKey: "onboarding_create_test_app_key"); + + final walletTypes = TestConfig.walletTypesUnderTest; + final firstType = walletTypes.first; + + tester.printToConsole("Creating first wallet: ${firstType.name}"); + + await onboardingFlows.createFirstWallet(firstType); + + await dashboardRobot.isDisplayed(); + await homePageRobot.isDisplayed(); + + final appStore = getIt.get(); + expect(appStore.wallet?.type, firstType); + + await homePageRobot.hasWalletName(appStore.wallet!.name); + + for (final type in walletTypes.skip(1)) { + tester.printToConsole("Creating additional wallet: ${type.name}"); + + await dashboardRobot.openWalletsTab(); + + await onboardingFlows.createAdditionalWalletFromWalletList(type); + + await dashboardRobot.isDisplayed(); + await homePageRobot.isDisplayed(); + + expect(appStore.wallet?.type, type); + + await homePageRobot.hasWalletName(appStore.wallet!.name); + } + }); +} diff --git a/integration_test/suites/tier0/onboarding_restore_test.dart b/integration_test/suites/tier0/onboarding_restore_test.dart new file mode 100644 index 000000000..ac5cf1110 --- /dev/null +++ b/integration_test/suites/tier0/onboarding_restore_test.dart @@ -0,0 +1,94 @@ +import "package:cake_wallet/di.dart"; +import "package:cake_wallet/store/app_store.dart"; +import "package:cw_core/wallet_type.dart"; +import "package:flutter_test/flutter_test.dart"; +import "package:integration_test/integration_test.dart"; + +import "../../core/app_launcher.dart"; +import "../../core/test_config.dart"; +import "../../core/test_wallets.dart"; +import "../../flows/onboarding_flows.dart"; +import "../../robots/home_page_robot.dart"; +import "../../robots/new_dashboard_robot.dart"; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + integrationTest("Restore a wallet from seed for each configured wallet type", (tester) async { + final appLauncher = AppLauncher(tester); + final onboardingFlows = OnboardingFlows(tester); + final dashboardRobot = NewDashboardRobot(tester); + final homePageRobot = HomePageRobot(tester); + + await appLauncher.launchApp(testKey: "onboarding_restore_test_app_key"); + + final walletTypes = TestConfig.walletTypesUnderTest; + final firstType = walletTypes.first; + + tester.printToConsole("Restoring first wallet: ${firstType.name}"); + + await onboardingFlows.restoreFirstWalletFromSeed(firstType); + + await dashboardRobot.isDisplayed(); + await homePageRobot.isDisplayed(); + + final appStore = getIt.get(); + expect(appStore.wallet?.type, firstType); + + await homePageRobot.hasWalletName(appStore.wallet!.name); + + await _expectRestoredTheRightWallet(homePageRobot, appStore, firstType); + + for (final type in walletTypes.skip(1)) { + tester.printToConsole("Restoring additional wallet: ${type.name}"); + + await dashboardRobot.openWalletsTab(); + + await onboardingFlows.restoreAdditionalWalletFromWalletList(type); + + await dashboardRobot.isDisplayed(); + await homePageRobot.isDisplayed(); + + expect(appStore.wallet?.type, type); + + await homePageRobot.hasWalletName(appStore.wallet!.name); + + await _expectRestoredTheRightWallet(homePageRobot, appStore, type); + } + }); +} + +Future _expectRestoredTheRightWallet( + HomePageRobot robot, + AppStore appStore, + WalletType type, +) async { + final expectedAddress = TestWallets.receiveAddressFor(type); + + if (expectedAddress.isEmpty) { + robot.tester.printToConsole("No known address for ${type.name}, derivation not verified"); + return; + } + + final settled = await robot.pumpUntil( + () { + final addresses = appStore.wallet?.walletAddresses; + + if (addresses == null) { + return false; + } + + return addresses.address == expectedAddress || + addresses.addressesMap.containsKey(expectedAddress) || + addresses.allAddressesMap.containsKey(expectedAddress); + }, + timeout: const Duration(seconds: 60), + ); + + expect( + settled, + true, + reason: "${type.name} restored from a known seed derived " + "${appStore.wallet?.walletAddresses.address} and does not know $expectedAddress", + ); +} diff --git a/integration_test/suites/tier0/receive_address_test.dart b/integration_test/suites/tier0/receive_address_test.dart new file mode 100644 index 000000000..ee781d2eb --- /dev/null +++ b/integration_test/suites/tier0/receive_address_test.dart @@ -0,0 +1,43 @@ +import "package:cake_wallet/di.dart"; +import "package:cake_wallet/store/app_store.dart"; +import "package:cw_core/wallet_type.dart"; +import "package:flutter_test/flutter_test.dart"; +import "package:integration_test/integration_test.dart"; + +import "../../core/app_launcher.dart"; +import "../../flows/onboarding_flows.dart"; +import "../../robots/home_page_robot.dart"; +import "../../robots/new_dashboard_robot.dart"; +import "../../robots/new_receive_page_robot.dart"; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + integrationTest("Receive sheet shows the opened wallet's own address", (tester) async { + final appLauncher = AppLauncher(tester); + final onboardingFlows = OnboardingFlows(tester); + final dashboardRobot = NewDashboardRobot(tester); + final homePageRobot = HomePageRobot(tester); + final receiveRobot = NewReceivePageRobot(tester); + + const walletType = WalletType.solana; + + await appLauncher.launchApp(testKey: "receive_address_test_app_key"); + + await onboardingFlows.restoreFirstWalletFromSeed(walletType); + + await dashboardRobot.isDisplayed(); + await homePageRobot.isDisplayed(); + + final expectedAddress = getIt.get().wallet!.walletAddresses.address; + expect(expectedAddress.isNotEmpty, true); + + await homePageRobot.openReceiveSheet(); + + await receiveRobot.isDisplayed(); + await receiveRobot.confirmAddressMatches(expectedAddress); + + await receiveRobot.dismissModal(); + await homePageRobot.isDisplayed(); + }); +} diff --git a/integration_test/suites/tier0/seed_confirmation_test.dart b/integration_test/suites/tier0/seed_confirmation_test.dart new file mode 100644 index 000000000..fbe840e24 --- /dev/null +++ b/integration_test/suites/tier0/seed_confirmation_test.dart @@ -0,0 +1,70 @@ +import "package:cake_wallet/di.dart"; +import "package:cake_wallet/routes.dart"; +import "package:cake_wallet/store/app_store.dart"; +import "package:cw_core/wallet_type.dart"; +import "package:flutter_test/flutter_test.dart"; +import "package:integration_test/integration_test.dart"; + +import "../../core/app_launcher.dart"; +import "../../flows/auth_flows.dart"; +import "../../flows/onboarding_flows.dart"; +import "../../robots/home_page_robot.dart"; +import "../../robots/new_dashboard_robot.dart"; +import "../../robots/new_settings_page_robot.dart"; +import "../../robots/show_keys_disclaimer_page_robot.dart"; +import "../../robots/wallet_keys_robot.dart"; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + integrationTest("Displayed seed and keys match the opened wallet", (tester) async { + final appLauncher = AppLauncher(tester); + final onboardingFlows = OnboardingFlows(tester); + final authFlows = AuthFlows(tester); + final dashboardRobot = NewDashboardRobot(tester); + final homePageRobot = HomePageRobot(tester); + final settingsRobot = NewSettingsPageRobot(tester); + final walletKeysRobot = WalletKeysAndSeedPageRobot(tester); + final disclaimerRobot = ShowKeysDisclaimerPageRobot(tester); + + // These three cover the different key display paths on the wallet keys page. + final walletTypes = [WalletType.solana, WalletType.bitcoin, WalletType.monero]; + + await appLauncher.launchApp(testKey: "seed_confirmation_test_app_key"); + + await onboardingFlows.createFirstWallet(walletTypes.first); + + await dashboardRobot.isDisplayed(); + await homePageRobot.isDisplayed(); + + final appStore = getIt.get(); + + for (final type in walletTypes) { + if (type != walletTypes.first) { + await dashboardRobot.openWalletsTab(); + await onboardingFlows.createAdditionalWalletFromWalletList(type); + await homePageRobot.isDisplayed(); + } + + expect(appStore.wallet?.type, type); + + tester.printToConsole("Confirming displayed credentials for ${type.name}"); + + await homePageRobot.openSettingsSheet(); + await settingsRobot.isDisplayed(); + + await settingsRobot.openRow(Routes.showKeysDisclaimer); + await disclaimerRobot.isDisplayed(); + await disclaimerRobot.tapShowKeys(); + await authFlows.authenticateWithPin(); + + await walletKeysRobot.isDisplayed(); + await walletKeysRobot.confirmWalletCredentials(type); + + await settingsRobot.goBack(); + await settingsRobot.dismissModal(); + + await homePageRobot.isDisplayed(); + } + }); +} diff --git a/integration_test/suites/tier0/send_from_address_book_test.dart b/integration_test/suites/tier0/send_from_address_book_test.dart new file mode 100644 index 000000000..24779f4f2 --- /dev/null +++ b/integration_test/suites/tier0/send_from_address_book_test.dart @@ -0,0 +1,59 @@ +import "package:cw_core/wallet_type.dart"; +import "package:flutter_test/flutter_test.dart"; +import "package:integration_test/integration_test.dart"; + +import "../../core/app_launcher.dart"; +import "../../core/test_wallets.dart"; +import "../../flows/onboarding_flows.dart"; +import "../../robots/contact_robot.dart"; +import "../../robots/home_page_robot.dart"; +import "../../robots/new_dashboard_robot.dart"; +import "../../robots/new_send_page_robot.dart"; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + integrationTest("A contact picked on the send screen fills in its own address", (tester) async { + final appLauncher = AppLauncher(tester); + final onboardingFlows = OnboardingFlows(tester); + final dashboardRobot = NewDashboardRobot(tester); + final homePageRobot = HomePageRobot(tester); + final contactRobot = ContactRobot(tester); + final sendRobot = NewSendPageRobot(tester); + + const contactName = "Send Book Contact"; + final contactAddress = TestWallets.receiveAddressFor(WalletType.solana); + + await appLauncher.launchApp(testKey: "send_from_address_book_test_app_key"); + + await onboardingFlows.createFirstWallet(WalletType.solana); + + await dashboardRobot.isDisplayed(); + await homePageRobot.isDisplayed(); + + await dashboardRobot.openContactsTab(); + await contactRobot.isDisplayed(); + + await contactRobot.openAddContact(); + await contactRobot.enterName(contactName); + await contactRobot.chooseCurrency("sol"); + await contactRobot.enterAddress(contactAddress); + await contactRobot.save(); + + await contactRobot.isDisplayed(); + + await dashboardRobot.openHomeTab(); + await homePageRobot.isDisplayed(); + + await homePageRobot.openSendSheet(); + await sendRobot.isDisplayed(); + + await sendRobot.pickContactFromAddressBook(contactName); + + expect( + sendRobot.enteredAddress(), + contactAddress, + reason: "The send screen was filled with an address the contact does not hold", + ); + }); +} diff --git a/integration_test/suites/tier0/send_validation_test.dart b/integration_test/suites/tier0/send_validation_test.dart new file mode 100644 index 000000000..1f347e0ec --- /dev/null +++ b/integration_test/suites/tier0/send_validation_test.dart @@ -0,0 +1,53 @@ +import "package:cw_core/wallet_type.dart"; +import "package:integration_test/integration_test.dart"; + +import "../../core/app_launcher.dart"; +import "../../core/test_wallets.dart"; +import "../../flows/onboarding_flows.dart"; +import "../../robots/home_page_robot.dart"; +import "../../robots/new_dashboard_robot.dart"; +import "../../robots/new_send_page_robot.dart"; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + integrationTest("Send screen refuses what it cannot send", (tester) async { + final appLauncher = AppLauncher(tester); + final onboardingFlows = OnboardingFlows(tester); + final dashboardRobot = NewDashboardRobot(tester); + final homePageRobot = HomePageRobot(tester); + final sendRobot = NewSendPageRobot(tester); + + const walletType = WalletType.solana; + + await appLauncher.launchApp(testKey: "send_validation_test_app_key"); + + await onboardingFlows.createFirstWallet(walletType); + + await dashboardRobot.isDisplayed(); + await homePageRobot.isDisplayed(); + + await homePageRobot.openSendSheet(); + await sendRobot.isDisplayed(); + + await sendRobot.tapSendButtonWhenReady(timeout: const Duration(minutes: 5)); + await sendRobot.expectNoTransactionBuilt(); + + // test that no transaction is built when the address is wrong or cannot be parsed + await sendRobot.enterAddress("not-a-real-address"); + await sendRobot.enterAmount("0.0001"); + await sendRobot.tapSendButton(); + + await sendRobot.expectNoTransactionBuilt(); + + // test that no transaction is built even if the address is valid but no balance + await sendRobot.enterAddress(TestWallets.receiveAddressFor(walletType)); + await sendRobot.enterAmount("0.0001"); + await sendRobot.tapSendButton(); + + await sendRobot.expectNoTransactionBuilt(); + + await sendRobot.dismissModal(); + await homePageRobot.isDisplayed(); + }); +} diff --git a/integration_test/suites/tier0/settings_nav_test.dart b/integration_test/suites/tier0/settings_nav_test.dart new file mode 100644 index 000000000..b599c9992 --- /dev/null +++ b/integration_test/suites/tier0/settings_nav_test.dart @@ -0,0 +1,61 @@ +import "package:cake_wallet/new-ui/pages/about_page.dart"; +import "package:cake_wallet/routes.dart"; +import "package:cake_wallet/src/screens/settings/connection_sync_page.dart"; +import "package:cake_wallet/src/screens/settings/display_settings_page.dart"; +import "package:cake_wallet/src/screens/settings/manage_nodes_page.dart"; +import "package:cake_wallet/src/screens/settings/other_settings_page.dart"; +import "package:cake_wallet/src/screens/settings/privacy_page.dart"; +import "package:cake_wallet/src/screens/settings/security_backup_page.dart"; +import "package:cw_core/wallet_type.dart"; +import "package:integration_test/integration_test.dart"; + +import "../../core/app_launcher.dart"; +import "../../flows/onboarding_flows.dart"; +import "../../robots/home_page_robot.dart"; +import "../../robots/new_dashboard_robot.dart"; +import "../../robots/new_settings_page_robot.dart"; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + integrationTest("Every settings row opens its page and backs out", (tester) async { + final appLauncher = AppLauncher(tester); + final onboardingFlows = OnboardingFlows(tester); + final dashboardRobot = NewDashboardRobot(tester); + final homePageRobot = HomePageRobot(tester); + final settingsRobot = NewSettingsPageRobot(tester); + + final rowsUnderTest = { + Routes.manageNodes: ManageNodesPage, + Routes.privacyPage: PrivacyPage, + Routes.otherSettingsPage: OtherSettingsPage, + Routes.connectionSync: ConnectionSyncPage, + Routes.displaySettingsPage: DisplaySettingsPage, + Routes.securityBackupPage: SecurityBackupPage, + Routes.aboutPage: AboutPage, + }; + + await appLauncher.launchApp(testKey: "settings_nav_test_app_key"); + + await onboardingFlows.createFirstWallet(WalletType.solana); + + await dashboardRobot.isDisplayed(); + await homePageRobot.isDisplayed(); + + await homePageRobot.openSettingsSheet(); + await settingsRobot.isDisplayed(); + + for (final entry in rowsUnderTest.entries) { + tester.printToConsole("Opening settings row ${entry.key}"); + + await settingsRobot.openRow(entry.key); + await settingsRobot.confirmLeafPageDisplayed(entry.value); + + await settingsRobot.goBack(); + await settingsRobot.isDisplayed(); + } + + await settingsRobot.dismissModal(); + await homePageRobot.isDisplayed(); + }); +} diff --git a/integration_test/suites/tier0/show_keys_auth_test.dart b/integration_test/suites/tier0/show_keys_auth_test.dart new file mode 100644 index 000000000..6ee6aa2e4 --- /dev/null +++ b/integration_test/suites/tier0/show_keys_auth_test.dart @@ -0,0 +1,61 @@ +import "package:cake_wallet/routes.dart"; +import "package:cake_wallet/src/screens/wallet_keys/wallet_keys_page.dart"; +import "package:cw_core/wallet_type.dart"; +import "package:flutter_test/flutter_test.dart"; +import "package:integration_test/integration_test.dart"; + +import "../../core/app_launcher.dart"; +import "../../flows/auth_flows.dart"; +import "../../flows/onboarding_flows.dart"; +import "../../robots/home_page_robot.dart"; +import "../../robots/new_dashboard_robot.dart"; +import "../../robots/new_settings_page_robot.dart"; +import "../../robots/show_keys_disclaimer_page_robot.dart"; +import "../../robots/wallet_keys_robot.dart"; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + integrationTest("Seed and keys page rejects a wrong pin and accepts the right one", + (tester) async { + final appLauncher = AppLauncher(tester); + final onboardingFlows = OnboardingFlows(tester); + final authFlows = AuthFlows(tester); + final dashboardRobot = NewDashboardRobot(tester); + final homePageRobot = HomePageRobot(tester); + final settingsRobot = NewSettingsPageRobot(tester); + final walletKeysRobot = WalletKeysAndSeedPageRobot(tester); + final disclaimerRobot = ShowKeysDisclaimerPageRobot(tester); + + final wrongPin = [9, 9, 9, 9]; + + await appLauncher.launchApp(testKey: "show_keys_auth_test_app_key"); + + await onboardingFlows.createFirstWallet(WalletType.solana); + + await dashboardRobot.isDisplayed(); + await homePageRobot.isDisplayed(); + + await homePageRobot.openSettingsSheet(); + await settingsRobot.isDisplayed(); + + await settingsRobot.openRow(Routes.showKeysDisclaimer); + await disclaimerRobot.isDisplayed(); + await disclaimerRobot.tapShowKeys(); + + await authFlows.authenticateWithPin(pin: wrongPin); + + // A wrong pin must keep the keys page locked away. + await settingsRobot.settle(max: const Duration(seconds: 3)); + expect(find.byType(WalletKeysPage), findsNothing); + expect(authFlows.isPinWidgetShown(), true); + + await authFlows.authenticateWithPin(); + + await walletKeysRobot.isDisplayed(); + + await settingsRobot.goBack(); + await settingsRobot.dismissModal(); + await homePageRobot.isDisplayed(); + }); +} diff --git a/integration_test/suites/tier0/wallet_delete_test.dart b/integration_test/suites/tier0/wallet_delete_test.dart new file mode 100644 index 000000000..183e6a035 --- /dev/null +++ b/integration_test/suites/tier0/wallet_delete_test.dart @@ -0,0 +1,85 @@ +import "package:cake_wallet/di.dart"; +import "package:cake_wallet/store/app_store.dart"; +import "package:cw_core/wallet_info.dart"; +import "package:cw_core/wallet_type.dart"; +import "package:flutter_test/flutter_test.dart"; +import "package:integration_test/integration_test.dart"; + +import "../../core/app_launcher.dart"; +import "../../flows/auth_flows.dart"; +import "../../flows/onboarding_flows.dart"; +import "../../robots/home_page_robot.dart"; +import "../../robots/new_dashboard_robot.dart"; +import "../../robots/wallet_edit_page_robot.dart"; +import "../../robots/wallet_list_page_robot.dart"; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + integrationTest("Deleting a wallet takes it off the list and out of storage", (tester) async { + final appLauncher = AppLauncher(tester); + final onboardingFlows = OnboardingFlows(tester); + final authFlows = AuthFlows(tester); + final dashboardRobot = NewDashboardRobot(tester); + final homePageRobot = HomePageRobot(tester); + final walletListRobot = WalletListPageRobot(tester); + final walletEditRobot = WalletEditPageRobot(tester); + + await appLauncher.launchApp(testKey: "wallet_delete_test_app_key"); + + await onboardingFlows.createFirstWallet(WalletType.solana); + + await dashboardRobot.isDisplayed(); + await homePageRobot.isDisplayed(); + + final appStore = getIt.get(); + final deletedWallet = appStore.wallet!.name; + + await dashboardRobot.openWalletsTab(); + await onboardingFlows.createAdditionalWalletFromWalletList(WalletType.ethereum); + + await homePageRobot.isDisplayed(); + + final keptWallet = appStore.wallet!.name; + + expect(keptWallet, isNot(deletedWallet), reason: "The second wallet never became the open one"); + + await dashboardRobot.openWalletsTab(); + await walletListRobot.isDisplayed(); + + await walletListRobot.openEditFor(deletedWallet); + await walletEditRobot.isDisplayed(); + + await walletEditRobot.tapDelete(); + await authFlows.authenticateWithPin(required: false); + + await walletEditRobot.confirmDelete(); + + await walletListRobot.isDisplayed(); + + final removed = await walletListRobot.pumpUntil( + () => !walletListRobot.hasWallet(deletedWallet), + timeout: const Duration(minutes: 1), + ); + + expect(removed, true, reason: "$deletedWallet is still on the list after being deleted"); + + expect( + walletListRobot.hasWallet(keptWallet), + true, + reason: "Deleting $deletedWallet took $keptWallet with it", + ); + + final stored = await WalletInfo.getAll(); + final names = stored.map((wallet) => wallet.name).toList(); + + expect(names, isNot(contains(deletedWallet))); + expect(names, contains(keptWallet)); + + expect( + appStore.wallet?.name, + keptWallet, + reason: "Deleting another wallet changed which one is open", + ); + }); +} diff --git a/integration_test/suites/tier0/wallet_group_test.dart b/integration_test/suites/tier0/wallet_group_test.dart new file mode 100644 index 000000000..903d724da --- /dev/null +++ b/integration_test/suites/tier0/wallet_group_test.dart @@ -0,0 +1,70 @@ +import "package:cake_wallet/di.dart"; +import "package:cake_wallet/store/app_store.dart"; +import "package:cw_core/wallet_type.dart"; +import "package:flutter_test/flutter_test.dart"; +import "package:integration_test/integration_test.dart"; + +import "../../core/app_launcher.dart"; +import "../../flows/onboarding_flows.dart"; +import "../../flows/wallet_flows.dart"; +import "../../robots/home_page_robot.dart"; +import "../../robots/new_dashboard_robot.dart"; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + integrationTest("A wallet added to an existing seed shares it and both stay reachable", + (tester) async { + final appLauncher = AppLauncher(tester); + final onboardingFlows = OnboardingFlows(tester); + final walletFlows = WalletFlows(tester); + final dashboardRobot = NewDashboardRobot(tester); + final homePageRobot = HomePageRobot(tester); + + const firstType = WalletType.solana; + const secondType = WalletType.ethereum; + + await appLauncher.launchApp(testKey: "wallet_group_test_app_key"); + + await onboardingFlows.createFirstWallet(firstType); + + await dashboardRobot.isDisplayed(); + await homePageRobot.isDisplayed(); + + final appStore = getIt.get(); + final firstWalletName = appStore.wallet!.name; + final sharedSeed = appStore.wallet!.seed; + + expect(sharedSeed, isNotNull, reason: "The first wallet has no seed to share"); + + await dashboardRobot.openWalletsTab(); + + await onboardingFlows.createWalletInGroupOf(secondType, firstWalletName); + + await dashboardRobot.isDisplayed(); + await homePageRobot.isDisplayed(); + + final secondWalletName = appStore.wallet!.name; + + expect( + secondWalletName, + isNot(firstWalletName), + reason: "Adding to a group opened the wallet that was already there", + ); + + expect(appStore.wallet?.type, secondType); + + expect( + appStore.wallet!.seed, + sharedSeed, + reason: "The wallet added to the group derived its own seed instead of sharing one", + ); + + await walletFlows.switchToWallet(firstWalletName); + + await homePageRobot.isDisplayed(); + await homePageRobot.hasWalletName(firstWalletName); + + expect(appStore.wallet?.seed, sharedSeed); + }); +} diff --git a/integration_test/suites/tier0/wallet_rename_test.dart b/integration_test/suites/tier0/wallet_rename_test.dart new file mode 100644 index 000000000..5163a0ca3 --- /dev/null +++ b/integration_test/suites/tier0/wallet_rename_test.dart @@ -0,0 +1,86 @@ +import "package:cake_wallet/di.dart"; +import "package:cake_wallet/store/app_store.dart"; +import "package:cw_core/wallet_info.dart"; +import "package:cw_core/wallet_type.dart"; +import "package:flutter_test/flutter_test.dart"; +import "package:integration_test/integration_test.dart"; + +import "../../core/app_launcher.dart"; +import "../../flows/onboarding_flows.dart"; +import "../../robots/home_page_robot.dart"; +import "../../robots/new_dashboard_robot.dart"; +import "../../robots/wallet_edit_page_robot.dart"; +import "../../robots/wallet_list_page_robot.dart"; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + integrationTest("A wallet can be renamed, but not onto a name already in use", (tester) async { + final appLauncher = AppLauncher(tester); + final onboardingFlows = OnboardingFlows(tester); + final dashboardRobot = NewDashboardRobot(tester); + final homePageRobot = HomePageRobot(tester); + final walletListRobot = WalletListPageRobot(tester); + final walletEditRobot = WalletEditPageRobot(tester); + + const renamedTo = "Renamed Test Wallet"; + + await appLauncher.launchApp(testKey: "wallet_rename_test_app_key"); + + await onboardingFlows.createFirstWallet(WalletType.solana); + + await dashboardRobot.isDisplayed(); + await homePageRobot.isDisplayed(); + + final appStore = getIt.get(); + final renamedWallet = appStore.wallet!.name; + + await dashboardRobot.openWalletsTab(); + await onboardingFlows.createAdditionalWalletFromWalletList(WalletType.ethereum); + + await homePageRobot.isDisplayed(); + + final openWallet = appStore.wallet!.name; + + expect(openWallet, isNot(renamedWallet), reason: "The second wallet never became the open one"); + + await dashboardRobot.openWalletsTab(); + await walletListRobot.isDisplayed(); + + await walletListRobot.openEditFor(renamedWallet); + await walletEditRobot.isDisplayed(); + + await walletEditRobot.enterName(openWallet); + await walletEditRobot.save(); + + await walletEditRobot.expectNameTakenRefusal(); + await walletEditRobot.dismissNameTakenRefusal(); + + await walletEditRobot.enterName(renamedTo); + await walletEditRobot.save(); + + await walletListRobot.isDisplayed(); + + final renamed = await walletListRobot.pumpUntil(() => walletListRobot.hasWallet(renamedTo)); + + expect(renamed, true, reason: "The list never showed $renamedTo after the rename was saved"); + + expect( + walletListRobot.hasWallet(renamedWallet), + false, + reason: "The list still shows $renamedWallet next to the name it was renamed to", + ); + + final stored = await WalletInfo.getAll(); + final names = stored.map((wallet) => wallet.name).toList(); + + expect(names, contains(renamedTo)); + expect(names, isNot(contains(renamedWallet))); + + expect( + appStore.wallet?.name, + openWallet, + reason: "Renaming another wallet changed which one is open", + ); + }); +} diff --git a/integration_test/suites/tier1/monero_legacy_seed_test.dart b/integration_test/suites/tier1/monero_legacy_seed_test.dart new file mode 100644 index 000000000..89155f875 --- /dev/null +++ b/integration_test/suites/tier1/monero_legacy_seed_test.dart @@ -0,0 +1,47 @@ +import "package:cake_wallet/di.dart"; +import "package:cake_wallet/store/app_store.dart"; +import "package:cw_core/wallet_type.dart"; +import "package:flutter_test/flutter_test.dart"; +import "package:integration_test/integration_test.dart"; + +import "../../core/app_launcher.dart"; +import "../../core/test_wallets.dart"; +import "../../flows/onboarding_flows.dart"; +import "../../robots/home_page_robot.dart"; +import "../../robots/new_dashboard_robot.dart"; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + integrationTest("A 25 word monero seed restores through the legacy path", (tester) async { + final appLauncher = AppLauncher(tester); + final onboardingFlows = OnboardingFlows(tester); + final dashboardRobot = NewDashboardRobot(tester); + final homePageRobot = HomePageRobot(tester); + + final legacySeed = TestWallets.moneroLegacySeed; + + expect( + legacySeed.split(" ").length, + 25, + reason: "The legacy seed secret is not a 25 word seed, this suite would restore a polyseed", + ); + + await appLauncher.launchApp(testKey: "monero_legacy_seed_test_app_key"); + + await onboardingFlows.restoreFirstWalletFromSeed(WalletType.monero, seed: legacySeed); + + await dashboardRobot.isDisplayed(); + await homePageRobot.isDisplayed(); + + final appStore = getIt.get(); + + expect(appStore.wallet?.type, WalletType.monero); + + expect( + appStore.wallet?.seed?.split(" ").length, + 25, + reason: "The wallet came back with a seed of a different length than the one restored", + ); + }); +} diff --git a/integration_test/suites/tier1/node_switching_test.dart b/integration_test/suites/tier1/node_switching_test.dart new file mode 100644 index 000000000..9ff1878df --- /dev/null +++ b/integration_test/suites/tier1/node_switching_test.dart @@ -0,0 +1,49 @@ +import "package:cake_wallet/routes.dart"; +import "package:cw_core/wallet_type.dart"; +import "package:flutter_test/flutter_test.dart"; +import "package:integration_test/integration_test.dart"; + +import "../../core/app_launcher.dart"; +import "../../flows/onboarding_flows.dart"; +import "../../robots/home_page_robot.dart"; +import "../../robots/manage_nodes_page_robot.dart"; +import "../../robots/new_dashboard_robot.dart"; +import "../../robots/new_settings_page_robot.dart"; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + integrationTest("Switching nodes changes which one the wallet is pointed at", (tester) async { + final appLauncher = AppLauncher(tester); + final onboardingFlows = OnboardingFlows(tester); + final dashboardRobot = NewDashboardRobot(tester); + final homePageRobot = HomePageRobot(tester); + final settingsRobot = NewSettingsPageRobot(tester); + final nodesRobot = ManageNodesPageRobot(tester); + + await appLauncher.launchApp(testKey: "node_switching_test_app_key"); + + await onboardingFlows.createFirstWallet(WalletType.solana); + + await dashboardRobot.isDisplayed(); + await homePageRobot.isDisplayed(); + + await homePageRobot.openSettingsSheet(); + await settingsRobot.isDisplayed(); + + await settingsRobot.openRow(Routes.manageNodes); + await nodesRobot.isDisplayed(); + + final initialNodeUri = nodesRobot.currentNodeUri(); + + expect(initialNodeUri, isNotEmpty, reason: "The wallet started with no node at all"); + + await nodesRobot.switchToAnotherNode(); + + expect( + nodesRobot.currentNodeUri(), + isNot(initialNodeUri), + reason: "The node was confirmed but the wallet is still pointed at $initialNodeUri", + ); + }); +} diff --git a/integration_test/suites/tier1/swap_quote_test.dart b/integration_test/suites/tier1/swap_quote_test.dart new file mode 100644 index 000000000..d7aa90a80 --- /dev/null +++ b/integration_test/suites/tier1/swap_quote_test.dart @@ -0,0 +1,37 @@ +import "package:cw_core/wallet_type.dart"; +import "package:integration_test/integration_test.dart"; + +import "../../core/app_launcher.dart"; +import "../../flows/onboarding_flows.dart"; +import "../../robots/home_page_robot.dart"; +import "../../robots/new_dashboard_robot.dart"; +import "../../robots/new_swap_page_robot.dart"; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + integrationTest("Swap sheet fetches a provider quote, no trade is created", (tester) async { + final appLauncher = AppLauncher(tester); + final onboardingFlows = OnboardingFlows(tester); + final dashboardRobot = NewDashboardRobot(tester); + final homePageRobot = HomePageRobot(tester); + final swapRobot = NewSwapPageRobot(tester); + + await appLauncher.launchApp(testKey: "swap_quote_test_app_key"); + + await onboardingFlows.restoreFirstWalletFromSeed(WalletType.solana); + + await dashboardRobot.isDisplayed(); + await homePageRobot.isDisplayed(); + + await homePageRobot.openSwapSheet(); + await swapRobot.isDisplayed(); + + await swapRobot.enterDepositAmount("1"); + + await swapRobot.confirmQuoteReceived(); + + await swapRobot.dismissModal(); + await homePageRobot.isDisplayed(); + }); +} diff --git a/integration_test/suites/tier1/sync_status_test.dart b/integration_test/suites/tier1/sync_status_test.dart new file mode 100644 index 000000000..902e91f40 --- /dev/null +++ b/integration_test/suites/tier1/sync_status_test.dart @@ -0,0 +1,27 @@ +import "package:cw_core/wallet_type.dart"; +import "package:integration_test/integration_test.dart"; + +import "../../core/app_launcher.dart"; +import "../../flows/onboarding_flows.dart"; +import "../../robots/home_page_robot.dart"; +import "../../robots/new_dashboard_robot.dart"; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + integrationTest("Restored wallet connects to a node and starts syncing", (tester) async { + final appLauncher = AppLauncher(tester); + final onboardingFlows = OnboardingFlows(tester); + final dashboardRobot = NewDashboardRobot(tester); + final homePageRobot = HomePageRobot(tester); + + await appLauncher.launchApp(testKey: "sync_status_test_app_key"); + + await onboardingFlows.restoreFirstWalletFromSeed(WalletType.bitcoin); + + await dashboardRobot.isDisplayed(); + await homePageRobot.isDisplayed(); + + await homePageRobot.confirmSyncIndicatorShown(); + }); +} diff --git a/integration_test/suites/tier1/transaction_details_test.dart b/integration_test/suites/tier1/transaction_details_test.dart new file mode 100644 index 000000000..317793319 --- /dev/null +++ b/integration_test/suites/tier1/transaction_details_test.dart @@ -0,0 +1,47 @@ +import "package:cw_core/wallet_type.dart"; +import "package:flutter_test/flutter_test.dart"; +import "package:integration_test/integration_test.dart"; + +import "../../core/app_launcher.dart"; +import "../../flows/onboarding_flows.dart"; +import "../../robots/home_page_robot.dart"; +import "../../robots/new_dashboard_robot.dart"; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + integrationTest("Tapping a transaction opens the details of that transaction", (tester) async { + final appLauncher = AppLauncher(tester); + final onboardingFlows = OnboardingFlows(tester); + final dashboardRobot = NewDashboardRobot(tester); + final homePageRobot = HomePageRobot(tester); + + await appLauncher.launchApp(testKey: "transaction_details_test_app_key"); + + await onboardingFlows.restoreFirstWalletFromSeed(WalletType.solana); + + await dashboardRobot.isDisplayed(); + await homePageRobot.isDisplayed(); + + await homePageRobot.confirmTransactionHistoryVisible(); + + await homePageRobot.openAllTransactions(); + await homePageRobot.confirmAllTransactionsVisible(); + + final tapped = homePageRobot.firstTransactionIdInAllView(); + + expect(tapped, isNotEmpty, reason: "The tile carries no transaction id to check against"); + + await homePageRobot.openFirstTransactionDetails(); + + // A list that hands the wrong transaction to the details screen shows one person's + // amounts under another's id, and nothing on the screen would say so. + expect( + homePageRobot.openedTransactionId(), + tapped, + reason: "The details opened for a different transaction than the one tapped", + ); + + homePageRobot.hasTransactionIdRow(); + }); +} diff --git a/integration_test/suites/tier1/transaction_history_test.dart b/integration_test/suites/tier1/transaction_history_test.dart new file mode 100644 index 000000000..db97cd105 --- /dev/null +++ b/integration_test/suites/tier1/transaction_history_test.dart @@ -0,0 +1,37 @@ +import "package:cake_wallet/di.dart"; +import "package:cake_wallet/store/app_store.dart"; +import "package:cw_core/wallet_type.dart"; +import "package:flutter_test/flutter_test.dart"; +import "package:integration_test/integration_test.dart"; + +import "../../core/app_launcher.dart"; +import "../../flows/onboarding_flows.dart"; +import "../../robots/home_page_robot.dart"; +import "../../robots/new_dashboard_robot.dart"; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + integrationTest("Transaction history renders for a wallet with known transactions", + (tester) async { + final appLauncher = AppLauncher(tester); + final onboardingFlows = OnboardingFlows(tester); + final dashboardRobot = NewDashboardRobot(tester); + final homePageRobot = HomePageRobot(tester); + + await appLauncher.launchApp(testKey: "transaction_history_test_app_key"); + + await onboardingFlows.restoreFirstWalletFromSeed(WalletType.solana); + + await dashboardRobot.isDisplayed(); + await homePageRobot.isDisplayed(); + + expect(getIt.get().wallet?.type, WalletType.solana); + + await homePageRobot.confirmTransactionHistoryVisible(); + + await homePageRobot.openAllTransactions(); + + await homePageRobot.confirmAllTransactionsVisible(); + }); +} diff --git a/integration_test/templates/example_feature_test.dart.example b/integration_test/templates/example_feature_test.dart.example new file mode 100644 index 000000000..84799cbce --- /dev/null +++ b/integration_test/templates/example_feature_test.dart.example @@ -0,0 +1,55 @@ +// Copy this file into integration_test/suites/tier0/ or tier1/, rename it to +// _test.dart and replace the example steps with your journey. +// The runner only picks up files ending in _test.dart, this template never runs. + +import "package:cw_core/wallet_type.dart"; +import "package:flutter_test/flutter_test.dart"; +import "package:integration_test/integration_test.dart"; + +import "../../core/app_launcher.dart"; +import "../../flows/onboarding_flows.dart"; +import "../../robots/home_page_robot.dart"; +import "../../robots/new_dashboard_robot.dart"; + +void main() { + // Required first line in every suite, integrationTest contains background app errors + // that testWidgets would turn into failures. + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + integrationTest("One sentence describing the journey under test", (tester) async { + // Robots and flows take the tester, create the ones your journey needs. + final appLauncher = AppLauncher(tester); + final onboardingFlows = OnboardingFlows(tester); + final dashboardRobot = NewDashboardRobot(tester); + final homePageRobot = HomePageRobot(tester); + + // Boots the real app, the key names the suite in failure output. + await appLauncher.launchApp(testKey: "example_feature_test_app_key"); + + // Every suite starts from a fresh install, so get to a working wallet first. + // Use restoreFirstWalletFromSeed instead when your feature needs a known wallet. + await onboardingFlows.createFirstWallet(WalletType.solana); + + await dashboardRobot.isDisplayed(); + await homePageRobot.isDisplayed(); + + // Drive your feature through its robot from here. Robots own the finders and + // assertions for their screen, the suite only sequences them: + // + // await homePageRobot.openSettingsSheet(); + // await mySettingsRobot.isDisplayed(); + // await mySettingsRobot.doTheThing(); + // + // Rules of thumb: + // - Wait with pumpUntilFound and pumpUntil, never with sleeps or pumpAndSettle. + // - Tap and read widgets by ValueKey, add keys to lib/ widgets when they are missing. + // - Assert app state through getIt (AppStore) or the page widget's view model. + // - A widget being in the tree does not mean it is on screen. Every dashboard tab + // stays mounted in an IndexedStack, so find.byType on a tab passes from any other + // tab. Check what is in front, the way WalletListPageRobot does. + // - A button being findable does not mean it is enabled. Tapping a disabled button + // does nothing at all and reads later as a missing widget somewhere else, so wait + // on whatever the screen itself gates on. + // - Add the finished suite to the table in docs/INTEGRATION_TESTS.md. + }); +} diff --git a/integration_test/test_suites/confirm_seeds_flow_test.dart b/integration_test/test_suites/confirm_seeds_flow_test.dart deleted file mode 100644 index 121f0315a..000000000 --- a/integration_test/test_suites/confirm_seeds_flow_test.dart +++ /dev/null @@ -1,122 +0,0 @@ -import 'package:cake_wallet/wallet_types.g.dart'; -import 'package:cw_core/wallet_type.dart'; -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:integration_test/integration_test.dart'; - -import '../components/common_test_constants.dart'; -import '../components/common_test_flows.dart'; -import '../robots/auth_page_robot.dart'; -import '../robots/dashboard_page_robot.dart'; -import '../robots/security_and_backup_page_robot.dart'; -import '../robots/wallet_keys_robot.dart'; - -void main() { - IntegrationTestWidgetsFlutterBinding.ensureInitialized(); - - AuthPageRobot authPageRobot; - CommonTestFlows commonTestFlows; - DashboardPageRobot dashboardPageRobot; - WalletKeysAndSeedPageRobot walletKeysAndSeedPageRobot; - SecurityAndBackupPageRobot securityAndBackupPageRobot; - - testWidgets( - 'Confirm if the seeds display properly', - (tester) async { - // Store the original FlutterError.onError handler - final originalOnError = FlutterError.onError; - - FlutterError.onError = (FlutterErrorDetails details) { - debugPrint('FlutterError caught: ${details.exception}'); - }; - - try { - authPageRobot = AuthPageRobot(tester); - commonTestFlows = CommonTestFlows(tester); - dashboardPageRobot = DashboardPageRobot(tester); - walletKeysAndSeedPageRobot = WalletKeysAndSeedPageRobot(tester); - securityAndBackupPageRobot = SecurityAndBackupPageRobot(tester); - - await commonTestFlows.startAppFlow( - ValueKey('confirm_creds_display_correctly_flow_app_key'), - ); - - await commonTestFlows.welcomePageToCreateNewWalletFlow( - WalletType.solana, - CommonTestConstants.pin, - ); - - await dashboardPageRobot.confirmWalletTypeIsDisplayedCorrectly(WalletType.solana); - - await _confirmSeedsFlowForWalletType( - WalletType.solana, - authPageRobot, - dashboardPageRobot, - securityAndBackupPageRobot, - walletKeysAndSeedPageRobot, - tester, - ); - - // Do the same for other available wallet types - for (var walletType in availableWalletTypes) { - if (walletType == WalletType.solana) { - continue; - } - - await dashboardPageRobot.navigateToWalletsListPage(isDrawer: true); - - await commonTestFlows.createNewWalletFromWalletMenu(walletType); - - await dashboardPageRobot.confirmWalletTypeIsDisplayedCorrectly(walletType); - - await _confirmSeedsFlowForWalletType( - walletType, - authPageRobot, - dashboardPageRobot, - securityAndBackupPageRobot, - walletKeysAndSeedPageRobot, - tester, - ); - } - - await Future.delayed(Duration(seconds: 15)); - } finally { - FlutterError.onError = originalOnError; - } - }, - ); -} - -Future _confirmSeedsFlowForWalletType( - WalletType walletType, - AuthPageRobot authPageRobot, - DashboardPageRobot dashboardPageRobot, - SecurityAndBackupPageRobot securityAndBackupPageRobot, - WalletKeysAndSeedPageRobot walletKeysAndSeedPageRobot, - WidgetTester tester, -) async { - await dashboardPageRobot.openDrawerMenu(); - await dashboardPageRobot.dashboardMenuWidgetRobot.navigateToSecurityAndBackupPage(); - - await securityAndBackupPageRobot.navigateToShowKeysPage(); - - final onAuthPage = authPageRobot.onAuthPage(); - if (onAuthPage) { - await authPageRobot.enterPinCode(CommonTestConstants.pin); - } - - final onAuthPageDesktop = authPageRobot.onAuthPageDesktop(); - if (onAuthPageDesktop) { - await authPageRobot.enterPassword(CommonTestConstants.pin.join("")); - } - await tester.pumpAndSettle(); - - await walletKeysAndSeedPageRobot.isWalletKeysAndSeedPage(); - walletKeysAndSeedPageRobot.hasTitle(); - walletKeysAndSeedPageRobot.hasShareWarning(); - - await walletKeysAndSeedPageRobot.confirmWalletCredentials(walletType); - - await walletKeysAndSeedPageRobot.backToDashboard(); -} diff --git a/integration_test/test_suites/create_wallet_flow_test.dart b/integration_test/test_suites/create_wallet_flow_test.dart deleted file mode 100644 index 2b290dcd3..000000000 --- a/integration_test/test_suites/create_wallet_flow_test.dart +++ /dev/null @@ -1,60 +0,0 @@ -import 'package:cake_wallet/wallet_types.g.dart'; -import 'package:cw_core/wallet_type.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:integration_test/integration_test.dart'; - -import '../components/common_test_constants.dart'; -import '../components/common_test_flows.dart'; -import '../robots/dashboard_page_robot.dart'; - -void main() { - IntegrationTestWidgetsFlutterBinding.ensureInitialized(); - - CommonTestFlows commonTestFlows; - DashboardPageRobot dashboardPageRobot; - - testWidgets( - 'Create Wallet Flow', - (tester) async { - FlutterError.onError = (FlutterErrorDetails details) { - debugPrint('FlutterError caught: ${details.exception}'); - }; - commonTestFlows = CommonTestFlows(tester); - dashboardPageRobot = DashboardPageRobot(tester); - - // Start the app - await commonTestFlows.startAppFlow( - ValueKey('create_wallets_through_seeds_test_app_key'), - ); - - await commonTestFlows.welcomePageToCreateNewWalletFlow( - WalletType.solana, - CommonTestConstants.pin, - ); - - // Confirm it actually restores a solana wallet - await dashboardPageRobot.confirmWalletTypeIsDisplayedCorrectly(WalletType.solana); - - // Do the same for other available wallet types - for (var walletType in availableWalletTypes) { - if (walletType == WalletType.solana) { - continue; - } - - await dashboardPageRobot.navigateToWalletsListPage(); - - await commonTestFlows.createNewWalletFromWalletMenu(walletType); - - await dashboardPageRobot.confirmWalletTypeIsDisplayedCorrectly(walletType); - } - - // Goes to the wallet menu and provides a confirmation that all the wallets were correctly restored - await dashboardPageRobot.navigateToWalletsListPage(); - - commonTestFlows.confirmAllAvailableWalletTypeIconsDisplayCorrectly(); - - await Future.delayed(Duration(seconds: 5)); - }, - ); -} diff --git a/integration_test/test_suites/exchange_flow_test.dart b/integration_test/test_suites/exchange_flow_test.dart deleted file mode 100644 index 0b876eff1..000000000 --- a/integration_test/test_suites/exchange_flow_test.dart +++ /dev/null @@ -1,65 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:integration_test/integration_test.dart'; - -import '../components/common_test_constants.dart'; -import '../components/common_test_flows.dart'; -import '../robots/dashboard_page_robot.dart'; -import '../robots/exchange_confirm_page_robot.dart'; -import '../robots/exchange_page_robot.dart'; -import '../robots/exchange_trade_external_send_page_robot.dart'; -import '../robots/exchange_trade_page_robot.dart'; -import 'package:cake_wallet/.secrets.g.dart' as secrets; - -void main() { - IntegrationTestWidgetsFlutterBinding.ensureInitialized(); - - CommonTestFlows commonTestFlows; - ExchangePageRobot exchangePageRobot; - DashboardPageRobot dashboardPageRobot; - ExchangeTradePageRobot exchangeTradePageRobot; - ExchangeConfirmPageRobot exchangeConfirmPageRobot; - ExchangeTradeExternalSendPageRobot exchangeTradeExternalSendPageRobot; - - testWidgets('Exchange flow', (tester) async { - FlutterError.onError = (FlutterErrorDetails details) { - debugPrint('FlutterError caught: ${details.exception}'); - }; - - commonTestFlows = CommonTestFlows(tester); - exchangePageRobot = ExchangePageRobot(tester); - dashboardPageRobot = DashboardPageRobot(tester); - exchangeTradePageRobot = ExchangeTradePageRobot(tester); - exchangeConfirmPageRobot = ExchangeConfirmPageRobot(tester); - exchangeTradeExternalSendPageRobot = ExchangeTradeExternalSendPageRobot(tester); - - await commonTestFlows.startAppFlow(ValueKey('exchange_app_test_key')); - await commonTestFlows.welcomePageToRestoreWalletThroughSeedsFlow( - CommonTestConstants.testWalletType, - secrets.solanaTestWalletSeeds, - CommonTestConstants.pin, - ); - await dashboardPageRobot.navigateToExchangePage(); - - // ----------- Exchange Page ------------- - await exchangePageRobot.selectDepositCurrency(CommonTestConstants.exchangeTestDepositCurrency); - await exchangePageRobot.selectReceiveCurrency(CommonTestConstants.exchangeTestReceiveCurrency); - - await exchangePageRobot.enterDepositAmount(CommonTestConstants.exchangeTestAmount); - await exchangePageRobot.enterDepositRefundAddress( - depositAddress: CommonTestConstants.testWalletAddress, - ); - await exchangePageRobot.enterReceiveAddress(CommonTestConstants.testWalletAddress); - await exchangePageRobot.onExchangeButtonPressed(); - await exchangePageRobot.handleErrors(CommonTestConstants.exchangeTestAmount); - - await exchangeConfirmPageRobot.onSavedTradeIdButtonPressed(); - - await exchangeTradePageRobot.onGotItButtonPressed(); - await exchangeTradePageRobot.onSendFromExternalButtonPressed(); - - await exchangeTradeExternalSendPageRobot.isExchangeTradeExternalSendPage(); - await exchangeTradeExternalSendPageRobot.verifySendDetailsItemsDisplayProperly(); - await exchangeTradeExternalSendPageRobot.onContinueButtonPressed(); - }); -} diff --git a/integration_test/test_suites/restore_wallet_through_seeds_flow_test.dart b/integration_test/test_suites/restore_wallet_through_seeds_flow_test.dart deleted file mode 100644 index 40957681c..000000000 --- a/integration_test/test_suites/restore_wallet_through_seeds_flow_test.dart +++ /dev/null @@ -1,75 +0,0 @@ -import 'package:cake_wallet/wallet_types.g.dart'; -import 'package:cw_core/wallet_type.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:integration_test/integration_test.dart'; - -import '../components/common_test_constants.dart'; -import '../components/common_test_flows.dart'; -import '../robots/dashboard_page_robot.dart'; -import 'package:cake_wallet/.secrets.g.dart' as secrets; - -void main() { - IntegrationTestWidgetsFlutterBinding.ensureInitialized(); - - CommonTestFlows commonTestFlows; - DashboardPageRobot dashboardPageRobot; - - testWidgets( - 'Restoring Wallets Through Seeds', - (tester) async { - FlutterError.onError = (FlutterErrorDetails details) { - debugPrint('FlutterError caught: ${details.exception}'); - }; - - commonTestFlows = CommonTestFlows(tester); - dashboardPageRobot = DashboardPageRobot(tester); - - // Start the app - await commonTestFlows.startAppFlow( - ValueKey('restore_wallets_through_seeds_test_app_key'), - ); - - // Restore the first wallet type: Solana - await commonTestFlows.welcomePageToRestoreWalletThroughSeedsFlow( - WalletType.solana, - secrets.solanaTestWalletSeeds, - CommonTestConstants.pin, - ); - - // Confirm it actually restores a solana wallet - await dashboardPageRobot.confirmWalletTypeIsDisplayedCorrectly(WalletType.solana); - - // Do the same for other available wallet types - for (var walletType in availableWalletTypes) { - if (walletType == WalletType.solana) { - continue; - } - final seed = commonTestFlows.getWalletSeedsByWalletType(walletType); - if (seed.isEmpty) { - tester.printToConsole("----------------------------"); - tester.printToConsole("- Skipped wallet: ${walletType}"); - tester.printToConsole("- Make sure to add seed to secrets"); - tester.printToConsole("----------------------------"); - continue; - } - - await dashboardPageRobot.navigateToWalletsListPage(); - - await commonTestFlows.restoreWalletFromWalletMenu( - walletType, - seed, - ); - - await dashboardPageRobot.confirmWalletTypeIsDisplayedCorrectly(walletType); - } - - // Goes to the wallet menu and provides a visual confirmation that all the wallets were correctly restored - await dashboardPageRobot.navigateToWalletsListPage(); - - commonTestFlows.confirmAllAvailableWalletTypeIconsDisplayCorrectly(); - - await Future.delayed(Duration(seconds: 5)); - }, - ); -} diff --git a/integration_test/test_suites/send_flow_test.dart b/integration_test/test_suites/send_flow_test.dart deleted file mode 100644 index 237de73bc..000000000 --- a/integration_test/test_suites/send_flow_test.dart +++ /dev/null @@ -1,133 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:integration_test/integration_test.dart'; -import 'package:cw_core/wallet_type.dart'; - -import '../components/common_test_constants.dart'; -import '../components/common_test_flows.dart'; -import '../components/common_test_cases.dart'; -import '../robots/dashboard_page_robot.dart'; -import '../robots/send_page_robot.dart'; -import 'package:cake_wallet/.secrets.g.dart' as secrets; - -void main() { - IntegrationTestWidgetsFlutterBinding.ensureInitialized(); - - SendPageRobot sendPageRobot; - CommonTestFlows commonTestFlows; - DashboardPageRobot dashboardPageRobot; - CommonTestCases commonTestCases; - - testWidgets('Send flow with wallet switching for insufficient balance - testing \$1 send', - (tester) async { - FlutterError.onError = (FlutterErrorDetails details) { - debugPrint('FlutterError caught: ${details.exception}'); - }; - - commonTestFlows = CommonTestFlows(tester); - sendPageRobot = SendPageRobot(tester: tester); - dashboardPageRobot = DashboardPageRobot(tester); - commonTestCases = CommonTestCases(tester); - - await commonTestFlows.startAppFlow(ValueKey('send_test_app_key')); - - // Various wallet options we want to use to test the send flow - final walletConfigs = [ - { - 'type': WalletType.solana, - 'seed': secrets.solanaTestWalletSeeds2, - 'name': 'Solana Wallet 1', - }, - { - 'type': WalletType.solana, - 'seed': secrets.solanaTestWalletSeeds, - 'name': 'Solana Wallet 2', - }, - { - 'type': WalletType.ethereum, - 'seed': secrets.ethereumTestWalletSeeds, - 'name': 'Ethereum Wallet', - }, - ]; - - bool hasSufficientBalance = false; - WalletType? successfulWalletType; - - // We try each wallet configuration until we find one with sufficient balance - for (int i = 0; i < walletConfigs.length; i++) { - final config = walletConfigs[i]; - - if ((config['seed'] as String).isEmpty) { - tester.printToConsole('Skipping ${config['name']}, seed is empty'); - continue; - } - - tester.printToConsole('Trying ${config['name']}'); - - // For first wallet, we restore wallet afresh, for subsequent ones, we do a wallet switch - if (i == 0) { - await commonTestFlows.welcomePageToRestoreWalletThroughSeedsFlow( - config['type'] as WalletType, - config['seed'] as String, - CommonTestConstants.pin, - ); - } else { - await commonTestCases.goBack(); - await commonTestCases.defaultSleepTime(); - - await dashboardPageRobot.navigateToWalletsListPage(); - await commonTestCases.defaultSleepTime(); - - await commonTestFlows.restoreWalletFromWalletMenu( - config['type'] as WalletType, - config['seed'] as String, - ); - } - - await dashboardPageRobot.confirmWalletTypeIsDisplayedCorrectly(config['type'] as WalletType); - - // Navigate to send page - await dashboardPageRobot.navigateToSendPage(); - await sendPageRobot.checkIfSendPageIsVisible(); - - await sendPageRobot.enterReceiveAddress(CommonTestConstants.testWalletAddress); - await sendPageRobot.selectReceiveCurrency(CommonTestConstants.sendTestReceiveCurrency); - - await sendPageRobot.selectTransactionPriority(); - - // Main check to see if this wallet has sufficient balance for $1 send - final hasBalance = await sendPageRobot.validateWalletBalanceForOneDollarSend(); - if (hasBalance) { - hasSufficientBalance = true; - successfulWalletType = config['type'] as WalletType; - tester.printToConsole( - '${config['name']} has sufficient balance for \$${CommonTestConstants.sendTestFiatAmount} send, proceeding with send', - ); - break; - } else { - tester.printToConsole( - '${config['name']} has insufficient balance for \$${CommonTestConstants.sendTestFiatAmount} send', - ); - if (i < walletConfigs.length - 1) { - tester.printToConsole('Switching to next wallet...'); - } - } - } - - // We only proceed with the send if we have sufficient balance - if (hasSufficientBalance && successfulWalletType != null) { - tester.printToConsole('Performing send transaction with $successfulWalletType wallet'); - - await sendPageRobot.testFiatAmountEntry(); - await sendPageRobot.testCryptoAmountEntry(); - - await sendPageRobot.onSendButtonPressed(); - await sendPageRobot.onSendSliderOnConfirmSendingBottomSheetDragged(); - await sendPageRobot.handleTransactionSuccessFlow(); - } else { - tester.printToConsole( - 'Test completed without sending - all available wallets have insufficient balance for \$${CommonTestConstants.sendTestFiatAmount} send', - ); - } - }); -} diff --git a/integration_test/test_suites/transaction_history_flow_test.dart b/integration_test/test_suites/transaction_history_flow_test.dart deleted file mode 100644 index c019ce6c4..000000000 --- a/integration_test/test_suites/transaction_history_flow_test.dart +++ /dev/null @@ -1,82 +0,0 @@ -import 'package:cw_core/wallet_type.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:integration_test/integration_test.dart'; - -import '../components/common_test_constants.dart'; -import '../components/common_test_flows.dart'; -import '../robots/dashboard_page_robot.dart'; -import '../robots/transactions_page_robot.dart'; -import 'package:cake_wallet/.secrets.g.dart' as secrets; - -void main() { - IntegrationTestWidgetsFlutterBinding.ensureInitialized(); - - CommonTestFlows commonTestFlows; - DashboardPageRobot dashboardPageRobot; - TransactionsPageRobot transactionsPageRobot; - - /// Two Test Scenarios - /// - Fully Synchronizes and display the transaction history either immediately or few seconds after fully synchronizing - /// - Displays the transaction history progressively as synchronizing happens - testWidgets('Transaction history flow', (tester) async { - FlutterError.onError = (FlutterErrorDetails details) { - debugPrint('FlutterError caught: ${details.exception}'); - }; - - commonTestFlows = CommonTestFlows(tester); - dashboardPageRobot = DashboardPageRobot(tester); - transactionsPageRobot = TransactionsPageRobot(tester); - - await commonTestFlows.startAppFlow( - ValueKey('confirm_creds_display_correctly_flow_app_key'), - ); - - /// Test Scenario 1 - Displays transaction history list while synchronizing. - /// - /// For Solana/Tron WalletTypes. - await commonTestFlows.welcomePageToRestoreWalletThroughSeedsFlow( - WalletType.solana, - secrets.solanaTestWalletSeeds, - CommonTestConstants.pin, - ); - - await dashboardPageRobot.confirmWalletTypeIsDisplayedCorrectly(WalletType.solana); - - await dashboardPageRobot.swipeDashboardTab(true); - - await transactionsPageRobot.isTransactionsPage(); - - await transactionsPageRobot.confirmTransactionsPageConstantsDisplayProperly(); - - // Wait time for the first scenario to ensure proper loading - await tester.pump(Duration(seconds: 3)); - await tester.pumpAndSettle(); - - await transactionsPageRobot.confirmTransactionHistoryListDisplaysCorrectly(true); - - /// Test Scenario 2 - Displays transaction history list after fully synchronizing. - /// - /// For Bitcoin/Monero/Wownero WalletTypes. - await dashboardPageRobot.navigateToWalletsListPage(); - - await commonTestFlows.restoreWalletFromWalletMenu( - WalletType.bitcoin, - secrets.bitcoinTestWalletSeeds, - ); - - await dashboardPageRobot.confirmWalletTypeIsDisplayedCorrectly(WalletType.bitcoin); - - await dashboardPageRobot.swipeDashboardTab(true); - - await transactionsPageRobot.isTransactionsPage(); - - await transactionsPageRobot.confirmTransactionsPageConstantsDisplayProperly(); - - // Wait time for the second scenario to ensure proper loading - await tester.pump(Duration(seconds: 3)); - await tester.pumpAndSettle(); - - await transactionsPageRobot.confirmTransactionHistoryListDisplaysCorrectly(false); - }); -} diff --git a/integration_test_runner.sh b/integration_test_runner.sh index de21b3edc..c38f07667 100755 --- a/integration_test_runner.sh +++ b/integration_test_runner.sh @@ -1,38 +1,65 @@ #!/bin/bash -# Exit on any error set -euo pipefail -# Configurations -export DESKTOP_FORCE_MOBILE="Y" -MAX_INACTIVITY=${MAX_INACTIVITY:-180} -MAX_PARALLEL_TESTS=${MAX_PARALLEL_TESTS:-2} -TEST_TIMEOUT=${TEST_TIMEOUT:-600} -RETRY_COUNT=${RETRY_COUNT:-1} +# Runner params/knobs, all overridable from the environment: +# SUITE_DIR directory to collect *_test.dart suites from, or a single suite file +# (default integration_test/suites) +# TEST_TIER tier0, tier1 or all, narrows SUITE_DIR to a tier subdirectory +# PLATFORM android, linux, macos or auto, picks the between-suite data reset +# ANDROID_APP_ID package cleared between suites on android (default read from android/app.properties) +# PREBUILT_APK prebuilt apk passed to flutter drive, its dart defines are already baked in +# EXTRA_DART_DEFINES semicolon separated KEY=VALUE pairs +# FLUTTER_DEVICE device id passed to flutter drive, needed when several devices are attached +# TEST_TIMEOUT per-attempt timeout in seconds (default 900) +# RETRY_COUNT retries per suite after a failure (default 1) +# MAX_VOID_ATTEMPTS extra attempts when the driver never attached (default 2) +# MAX_TOTAL_VOID_ATTEMPTS give up on the whole run after this many, the environment is broken (default 4) +# REMOVE_DATA_DIRECTORY set to N to keep app data between suites (default wipes it) +# REMOVE_DESKTOP_DATA set to Y to let a desktop run wipe the wallet directories below, +# they belong to the installed wallet so this is off by default +# SUMMARY_FILE path to write a key=value summary of the run to, for reporting +# VOID_GRACE seconds to let a driver that cannot attach recover before the +# attempt is ended early (default 60) -# Data directories to clean +SUITE_DIR=${SUITE_DIR:-integration_test/suites} +TEST_TIER=${TEST_TIER:-all} +PLATFORM=${PLATFORM:-auto} +TEST_TIMEOUT=${TEST_TIMEOUT:-900} +RETRY_COUNT=${RETRY_COUNT:-1} +MAX_VOID_ATTEMPTS=${MAX_VOID_ATTEMPTS:-2} +MAX_TOTAL_VOID_ATTEMPTS=${MAX_TOTAL_VOID_ATTEMPTS:-4} +REMOVE_DATA_DIRECTORY=${REMOVE_DATA_DIRECTORY:-Y} +REMOVE_DESKTOP_DATA=${REMOVE_DESKTOP_DATA:-N} +EXTRA_DART_DEFINES=${EXTRA_DART_DEFINES:-} +SUMMARY_FILE=${SUMMARY_FILE:-} +VOID_GRACE=${VOID_GRACE:-60} + +# Using this to capture as many of these annoying flutter drive errors as possible, +# so we can handle them, and the test doesn't just hang forever +VOID_MARKERS="unusually long time to connect to the VM|taking unusually long time to initialize|Service has disappeared|Flutter Driver extension is taking a long time to become available" +PREBUILT_APK=${PREBUILT_APK:-} + +# Desktop data directories, shared with the installed wallet, see REMOVE_DESKTOP_DATA DATA_DIRS=( "$HOME/.local/share/com.example.cake_wallet" "$HOME/Documents/cake_wallet" "$HOME/.config/cake_wallet" ) -# Global state -declare -a targets -declare -a passed_tests -declare -a failed_tests -declare -a test_durations -declare -a test_start_times +targets=() +passed_tests=() +failed_tests=() +funds_results=() +total_void_attempts=0 -# Signal handling cleanup() { - echo "šŸ›‘ Received interrupt signal, cleaning up..." + echo "Received interrupt signal, cleaning up..." exit 1 } trap cleanup SIGINT SIGTERM -# Utility functions log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" } @@ -41,7 +68,6 @@ error() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] ERROR: $1" >&2 } -# Format seconds into hours, minutes, seconds format_duration() { local seconds=$1 local hours=$((seconds / 3600)) @@ -57,101 +83,356 @@ format_duration() { fi } -clean_data_directories() { - if [[ "${REMOVE_DATA_DIRECTORY:-}" == "Y" ]]; then - log "Cleaning data directories..." - for dir in "${DATA_DIRS[@]}"; do - if [[ -d "$dir" ]]; then - rm -rf "$dir" || error "Failed to remove $dir" - fi - done +resolve_platform() { + if [[ "$PLATFORM" != "auto" ]]; then + return + fi + + if command -v adb > /dev/null 2>&1 && adb devices 2>/dev/null | grep -q "device$"; then + PLATFORM="android" + elif [[ "$(uname -s)" == "Darwin" ]]; then + PLATFORM="macos" + else + PLATFORM="linux" + fi + + log "Resolved platform: $PLATFORM" +} + +resolve_android_app_id() { + if [[ -n "${ANDROID_APP_ID:-}" ]]; then + return + fi + + if [[ -f "android/app.properties" ]]; then + ANDROID_APP_ID=$(grep '^id=' android/app.properties | cut -d'=' -f2) + fi + + ANDROID_APP_ID=${ANDROID_APP_ID:-com.cakewallet.cake_wallet} +} + +restart_adb() { + if [[ "$PLATFORM" != "android" ]]; then + return + fi + + bounded 30 adb kill-server + bounded 30 adb start-server + bounded 60 adb wait-for-device + + if ! bounded 30 adb devices | grep -q "device$"; then + error "No device is attached after restarting adb" + return 1 fi } -# Run a single test with retry logic +bounded() { + local limit="$1" + shift + + if command -v timeout > /dev/null 2>&1; then + timeout "$limit" "$@" 2>/dev/null || return 1 + elif command -v perl > /dev/null 2>&1; then + perl -e 'alarm shift; exec @ARGV' "$limit" "$@" 2>/dev/null || return 1 + else + "$@" 2>/dev/null || return 1 + fi +} + +capture_failure_logcat() { + local test_name="$1" + + if [[ "$PLATFORM" != "android" ]]; then + return + fi + + echo "===== logcat after failed attempt: $test_name =====" + bounded 60 adb logcat -d -t 400 || echo "logcat unavailable" + echo "===== end logcat =====" +} + +clean_data_directories() { + if [[ "$REMOVE_DATA_DIRECTORY" != "Y" ]]; then + return + fi + + if [[ "$PLATFORM" == "android" ]]; then + if ! command -v adb > /dev/null 2>&1; then + error "adb is not on PATH, every suite would run on whatever data is already installed" + exit 1 + fi + + if [[ "$ANDROID_APP_ID" != *".test_"* ]]; then + error "Refusing to clear $ANDROID_APP_ID, it is not a test build. Rename the app first: printf 'id=com.cakewallet.test_local\\nname=local\\n' > android/app.properties" + exit 1 + fi + + log "Cleaning app data..." + adb shell pm clear "$ANDROID_APP_ID" > /dev/null 2>&1 || log "pm clear skipped, $ANDROID_APP_ID not installed yet" + return + fi + + if [[ "$REMOVE_DESKTOP_DATA" != "Y" ]]; then + log "Keeping the desktop wallet data, set REMOVE_DESKTOP_DATA=Y to wipe it between suites" + return + fi + + log "Cleaning app data..." + + for dir in "${DATA_DIRS[@]}"; do + if [[ -d "$dir" ]]; then + rm -rf "$dir" || error "Failed to remove $dir" + fi + done +} + +build_drive_command() { + drive_command=(flutter drive --driver=test_driver/integration_test.dart --dart-define=CI_BUILD=true) + + if [[ -n "${FLUTTER_DEVICE:-}" ]]; then + drive_command+=("-d" "$FLUTTER_DEVICE") + fi + + if [[ -n "$PREBUILT_APK" ]]; then + drive_command+=("--use-application-binary=$PREBUILT_APK") + fi + + if [[ -n "$EXTRA_DART_DEFINES" ]]; then + IFS=';' read -ra defines <<< "$EXTRA_DART_DEFINES" + for define in "${defines[@]}"; do + drive_command+=("--dart-define=$define") + done + fi + + if command -v timeout > /dev/null 2>&1; then + drive_command=(timeout "$TEST_TIMEOUT" "${drive_command[@]}") + elif command -v perl > /dev/null 2>&1; then + drive_command=(perl -e 'alarm shift; exec @ARGV' "$TEST_TIMEOUT" "${drive_command[@]}") + fi +} + +end_process_tree() { + local root="$1" signal="$2" + local child + + for child in $(pgrep -P "$root" 2>/dev/null); do + end_process_tree "$child" "$signal" + done + + kill "-$signal" "$root" 2>/dev/null || true +} + +watch_for_dead_driver() { + local log_file="$1" drive_pid="$2" + local marker_seen=0 + + while kill -0 "$drive_pid" 2>/dev/null; do + if (( marker_seen == 0 )) && grep -qE "$VOID_MARKERS" "$log_file" 2>/dev/null; then + marker_seen=$(date +%s) + log "Driver is struggling to attach, giving it ${VOID_GRACE}s to recover" + fi + + if (( marker_seen > 0 )) && (( $(date +%s) - marker_seen >= VOID_GRACE )); then + log "Driver never attached within ${VOID_GRACE}s, ending this attempt early" + + end_process_tree "$drive_pid" TERM + sleep 5 + end_process_tree "$drive_pid" KILL + + return 0 + fi + + sleep 5 + done +} + +collect_funds_results() { + local log_file="$1" + local line + + while IFS= read -r line; do + funds_results+=("$line") + done < <(sed -n 's/.*FUNDS_RESULT|//p' "$log_file" | sort -u) +} + run_test() { local test_file="$1" local test_name=$(basename "$test_file" .dart) local retry_count=0 + local void_attempts=0 while (( retry_count <= RETRY_COUNT )); do log "Running test: $test_name (attempt $((retry_count + 1)))" - # Clean data directories before each attempt clean_data_directories - # Record start time local start_time=$(date +%s) - test_start_times+=("$start_time") + local attempt_log + attempt_log=$(mktemp) - # Run the test in foreground - if flutter drive \ - --driver=test_driver/integration_test.dart \ - --target="$test_file" \ - --dart-define=CI_BUILD=true; then - # Calculate duration - local end_time=$(date +%s) - local duration=$((end_time - start_time)) - test_durations+=("$duration") + "${drive_command[@]}" --target="$test_file" > >(tee "$attempt_log") 2>&1 & + local drive_pid=$! - log "āœ… Test passed: $test_name ($(format_duration $duration))" - passed_tests+=("$test_name") + watch_for_dead_driver "$attempt_log" "$drive_pid" & + local watchdog_pid=$! + disown "$watchdog_pid" 2>/dev/null || true + + local attempt_status=0 + wait "$drive_pid" 2>/dev/null || attempt_status=$? + + kill "$watchdog_pid" 2>/dev/null || true + + if (( attempt_status == 0 )) && ! grep -q "All tests passed." "$attempt_log"; then + attempt_status=1 + fi + + if (( attempt_status == 0 )); then + local duration=$(( $(date +%s) - start_time )) + + log "PASS: $test_name ($(format_duration $duration))" + passed_tests+=("$test_name|$duration") + collect_funds_results "$attempt_log" + rm -f "$attempt_log" return 0 else - # Calculate duration even for failed tests - local end_time=$(date +%s) - local duration=$((end_time - start_time)) - test_durations+=("$duration") + local duration=$(( $(date +%s) - start_time )) - log "āŒ Test failed: $test_name ($(format_duration $duration))" + local attempt_was_void=N + if grep -qE "$VOID_MARKERS" "$attempt_log"; then + attempt_was_void=Y + fi - if (( retry_count < RETRY_COUNT )); then + collect_funds_results "$attempt_log" + + rm -f "$attempt_log" + + log "FAIL: $test_name ($(format_duration $duration))" + + capture_failure_logcat "$test_name" + + if [[ "$attempt_was_void" == "Y" ]]; then + total_void_attempts=$((total_void_attempts + 1)) + + if (( total_void_attempts > MAX_TOTAL_VOID_ATTEMPTS )); then + error "The flutter driver failed to attach $total_void_attempts times, the emulator or adb is broken" + failed_tests+=("$test_name|$duration|void") + return 1 + fi + fi + + if [[ "$attempt_was_void" == "Y" ]] && (( void_attempts < MAX_VOID_ATTEMPTS )); then + log "Driver never attached to the app, retrying without spending a retry" + void_attempts=$((void_attempts + 1)) + + if ! restart_adb; then + error "Could not bring adb back, aborting this suite" + failed_tests+=("$test_name|$duration|void") + return 1 + fi + + sleep 5 + elif (( retry_count < RETRY_COUNT )); then log "Retrying test: $test_name" retry_count=$((retry_count + 1)) - sleep 5 # Brief pause before retry + sleep 5 else - failed_tests+=("$test_name") + local fail_reason="test" + [[ "$attempt_was_void" == "Y" ]] && fail_reason="void" + + failed_tests+=("$test_name|$duration|$fail_reason") return 1 fi fi done } -# Main execution flow +write_github_summary() { + if [[ -z "${GITHUB_STEP_SUMMARY:-}" ]]; then + return + fi + + { + echo "### Integration tests ($SUITE_DIR, tier: $TEST_TIER)" + echo "" + echo "| Suite | Result | Duration |" + echo "|---|---|---|" + for entry in "${passed_tests[@]+"${passed_tests[@]}"}"; do + echo "| ${entry%%|*} | pass | $(format_duration "${entry##*|}") |" + done + for entry in "${failed_tests[@]+"${failed_tests[@]}"}"; do + local rest="${entry#*|}" + echo "| ${entry%%|*} | ${rest#*|} | $(format_duration "${rest%%|*}") |" + done + } >> "$GITHUB_STEP_SUMMARY" +} + +write_summary_file() { + if [[ -z "$SUMMARY_FILE" ]]; then + return + fi + + { + echo "tier=$TEST_TIER" + echo "total=${#targets[@]}" + echo "passed=${#passed_tests[@]}" + echo "failed=${#failed_tests[@]}" + echo "duration=$1" + for entry in "${passed_tests[@]+"${passed_tests[@]}"}"; do + echo "pass=$entry" + done + for entry in "${failed_tests[@]+"${failed_tests[@]}"}"; do + echo "fail=$entry" + done + for entry in "${funds_results[@]+"${funds_results[@]}"}"; do + echo "chain=$entry" + done + } > "$SUMMARY_FILE" +} + main() { log "Starting integration test runner" - log "Configuration: RETRY_COUNT=${RETRY_COUNT}" - # Collect all Dart test files from test_suites directory - while IFS= read -r -d $'\0' file; do - targets+=("$file") - done < <(find integration_test/test_suites -name "*.dart" -type f -print0) + local search_dir="$SUITE_DIR" + if [[ "$TEST_TIER" != "all" ]]; then + search_dir="$SUITE_DIR/$TEST_TIER" + fi - if [[ $? -ne 0 ]]; then - error "Failed to find test files" + if [[ -f "$SUITE_DIR" ]]; then + search_dir="$SUITE_DIR" + elif [[ ! -d "$search_dir" ]]; then + error "Suite directory not found: $search_dir" exit 1 fi + resolve_platform + resolve_android_app_id + build_drive_command + + log "Configuration: SUITE_DIR=$search_dir PLATFORM=$PLATFORM RETRY_COUNT=$RETRY_COUNT" + + if [[ -f "$search_dir" ]]; then + targets+=("$search_dir") + else + while IFS= read -r -d $'\0' file; do + targets+=("$file") + done < <(find "$search_dir" -name "*_test.dart" -type f -print0 | sort -z) + fi + if (( ${#targets[@]} == 0 )); then - error "No test files found in integration_test/test_suites directory" + error "No test files found in $search_dir" exit 1 fi log "Found ${#targets[@]} test files" - # Record overall start time local overall_start_time=$(date +%s) - # Run tests sequentially for target in "${targets[@]}"; do - run_test "$target" + run_test "$target" || true done - # Calculate total duration - local overall_end_time=$(date +%s) - local total_duration=$((overall_end_time - overall_start_time)) + local total_duration=$(( $(date +%s) - overall_start_time )) - # Generate summary echo -e "\n===== Test Summary =====" echo "Total tests: ${#targets[@]}" echo "Passed: ${#passed_tests[@]}" @@ -159,26 +440,26 @@ main() { echo "Total duration: $(format_duration $total_duration)" if (( ${#passed_tests[@]} > 0 )); then - echo -e "\nāœ… Passed Tests:" - for i in $(seq 0 $((${#passed_tests[@]} - 1))); do - local test_name="${passed_tests[$i]}" - local duration="${test_durations[$i]}" - echo " - $test_name ($(format_duration $duration))" + echo -e "\nPassed Tests:" + for entry in "${passed_tests[@]}"; do + echo " - ${entry%%|*} ($(format_duration "${entry##*|}"))" done fi + write_github_summary + write_summary_file "$total_duration" + if (( ${#failed_tests[@]} > 0 )); then - echo -e "\nāŒ Failed Tests:" - for i in $(seq 0 $((${#failed_tests[@]} - 1))); do - local test_name="${failed_tests[$i]}" - local duration="${test_durations[$i]}" - echo " - $test_name ($(format_duration $duration))" + echo -e "\nFailed Tests:" + local rest + for entry in "${failed_tests[@]}"; do + rest="${entry#*|}" + echo " - ${entry%%|*} ($(format_duration "${rest%%|*}"), ${rest#*|})" done exit 1 - else - echo -e "\nšŸŽ‰ All tests passed successfully!" fi + + echo -e "\nAll tests passed successfully!" } -# Run main function main "$@" diff --git a/lib/new-ui/new_dashboard.dart b/lib/new-ui/new_dashboard.dart index 898d00829..eadf0d222 100644 --- a/lib/new-ui/new_dashboard.dart +++ b/lib/new-ui/new_dashboard.dart @@ -71,6 +71,7 @@ class _NewDashboardState extends State { bottomSheetService: widget.bottomSheetService, child: CupertinoScaffold( body: Material( + key: ValueKey("new_dashboard_page_key"), child: Stack( alignment: Alignment.bottomCenter, children: [ diff --git a/lib/new-ui/pages/lightning_username_page.dart b/lib/new-ui/pages/lightning_username_page.dart index c42c19f0b..9ab8a5c6a 100644 --- a/lib/new-ui/pages/lightning_username_page.dart +++ b/lib/new-ui/pages/lightning_username_page.dart @@ -112,6 +112,7 @@ class _LightningUsernamePageState extends State { ), SizedBox(), NewPrimaryButton( + key: ValueKey("lightning_username_page_skip_button_key"), onPressed: () { Navigator.of(context).popUntil((route) => route.isFirst); }, diff --git a/lib/new-ui/pages/send_page.dart b/lib/new-ui/pages/send_page.dart index a6ab1625a..e78b5d247 100644 --- a/lib/new-ui/pages/send_page.dart +++ b/lib/new-ui/pages/send_page.dart @@ -464,6 +464,7 @@ class _NewSendPageState extends State { child: Text(S.of(context).address_or_alias), ), NewSendAddressInput( + key: ValueKey("send_page_address_input_key"), displayName: output.displayName, validator: output.isParsedAddress ? widget.sendViewModel.textValidator @@ -534,6 +535,7 @@ class _NewSendPageState extends State { // test/new-ui/widgets/send_page/send_amount_input_test.dart. Text(S.of(context).amount), NewSendAmountInput( + key: ValueKey("send_page_amount_input_key"), validator: output.sendAll ? widget.sendViewModel.allAmountValidator : widget.sendViewModel.amountValidator(output), diff --git a/lib/new-ui/pages/settings_page.dart b/lib/new-ui/pages/settings_page.dart index 313c5b86c..a2615cae7 100644 --- a/lib/new-ui/pages/settings_page.dart +++ b/lib/new-ui/pages/settings_page.dart @@ -146,7 +146,7 @@ class SettingsMainPage extends StatelessWidget { List buildItems(SettingsSectionData section) => section.items .map((item) => item.condition(dashboardViewModel) ? ListItemRegularRow( - keyValue: item.title, + keyValue: item.route, label: item.title, iconPath: item.iconPath, onTap: () { diff --git a/lib/new-ui/widgets/coins_page/action_row/coin_action_row.dart b/lib/new-ui/widgets/coins_page/action_row/coin_action_row.dart index 52082f0e9..c83e40fc5 100644 --- a/lib/new-ui/widgets/coins_page/action_row/coin_action_row.dart +++ b/lib/new-ui/widgets/coins_page/action_row/coin_action_row.dart @@ -44,6 +44,7 @@ class CoinActionRow extends StatelessWidget { spacing: MediaQuery.of(context).size.width * 0.05, children: [ CoinActionButton( + key: ValueKey("home_page_send_button_key"), icon: CakeImageWidget( imageUrl: "assets/new-ui/send.svg", colorFilter: ColorFilter.mode( @@ -76,6 +77,7 @@ class CoinActionRow extends StatelessWidget { }, ), CoinActionButton( + key: ValueKey("home_page_receive_button_key"), icon: CakeImageWidget( imageUrl: "assets/new-ui/receive.svg", colorFilter: ColorFilter.mode( @@ -97,6 +99,7 @@ class CoinActionRow extends StatelessWidget { ), if (showSwap) CoinActionButton( + key: ValueKey("home_page_swap_button_key"), icon: CakeImageWidget( imageUrl: "assets/new-ui/exchange.svg", colorFilter: ColorFilter.mode( @@ -120,6 +123,7 @@ class CoinActionRow extends StatelessWidget { }, ), CoinActionButton( + key: ValueKey("home_page_scan_button_key"), icon: CakeImageWidget( imageUrl: "assets/new-ui/scan.svg", colorFilter: ColorFilter.mode( diff --git a/lib/new-ui/widgets/coins_page/assets_history/assets_top_bar.dart b/lib/new-ui/widgets/coins_page/assets_history/assets_top_bar.dart index cceb6ac08..17b008a52 100644 --- a/lib/new-ui/widgets/coins_page/assets_history/assets_top_bar.dart +++ b/lib/new-ui/widgets/coins_page/assets_history/assets_top_bar.dart @@ -67,6 +67,7 @@ class AssetsTopBar extends StatelessWidget { child: Semantics( button: true, child: GestureDetector( + key: const ValueKey("assets_history_action_button_key"), onTap: actionButton.onPressed, child: Container( height: 40, diff --git a/lib/new-ui/widgets/coins_page/assets_history/history_modal.dart b/lib/new-ui/widgets/coins_page/assets_history/history_modal.dart index f758b5406..c14c54c09 100644 --- a/lib/new-ui/widgets/coins_page/assets_history/history_modal.dart +++ b/lib/new-ui/widgets/coins_page/assets_history/history_modal.dart @@ -18,6 +18,7 @@ class HistoryModal extends StatelessWidget { @override Widget build(BuildContext context) { return Container( + key: const ValueKey("history_modal_key"), decoration: BoxDecoration( color: Theme.of(context).colorScheme.surface, borderRadius: BorderRadius.vertical(top: Radius.circular(18))), diff --git a/lib/new-ui/widgets/coins_page/assets_history/history_section.dart b/lib/new-ui/widgets/coins_page/assets_history/history_section.dart index d27835ee7..182dfcfe1 100644 --- a/lib/new-ui/widgets/coins_page/assets_history/history_section.dart +++ b/lib/new-ui/widgets/coins_page/assets_history/history_section.dart @@ -116,6 +116,7 @@ class HistorySection extends StatelessWidget { } }, child: HistoryTile( + key: ValueKey("home_page_transaction_${transaction.id}_key"), title: item.formattedTitle + transactionType, date: _formatTransactionDate(item.date, localeName), amount: item.formattedCryptoAmount, diff --git a/lib/new-ui/widgets/coins_page/assets_history/history_top_bar.dart b/lib/new-ui/widgets/coins_page/assets_history/history_top_bar.dart index 0203d59ba..4acd94e8a 100644 --- a/lib/new-ui/widgets/coins_page/assets_history/history_top_bar.dart +++ b/lib/new-ui/widgets/coins_page/assets_history/history_top_bar.dart @@ -17,6 +17,7 @@ class HistoryTopBar extends StatelessWidget { onTap: onTap, child: ExcludeSemantics( child: GestureDetector( + key: const ValueKey("history_top_bar_key"), onTap: onTap, behavior: HitTestBehavior.opaque, child: Padding( diff --git a/lib/new-ui/widgets/coins_page/top_bar_widget/sync_bar.dart b/lib/new-ui/widgets/coins_page/top_bar_widget/sync_bar.dart index 9bf3ec045..0ea80298f 100644 --- a/lib/new-ui/widgets/coins_page/top_bar_widget/sync_bar.dart +++ b/lib/new-ui/widgets/coins_page/top_bar_widget/sync_bar.dart @@ -153,7 +153,11 @@ class SyncBar extends StatelessWidget { width: 20, height: 20, ), - if (_showDot()) const CupertinoActivityIndicator(radius: 8), + if (_showDot()) + CupertinoActivityIndicator( + key: ValueKey(dashboardViewModel.status.runtimeType), + radius: 8, + ), if (_showLightSyncCheck()) const Icon(Icons.check, color: syncedColor, size: 18), ], ); diff --git a/lib/new-ui/widgets/coins_page/top_bar_widget/top_bar.dart b/lib/new-ui/widgets/coins_page/top_bar_widget/top_bar.dart index 026af46ba..91e28fc02 100644 --- a/lib/new-ui/widgets/coins_page/top_bar_widget/top_bar.dart +++ b/lib/new-ui/widgets/coins_page/top_bar_widget/top_bar.dart @@ -165,6 +165,7 @@ class _TopBarState extends State { ), ), ModernButton.svg( + key: const ValueKey("home_page_settings_button_key"), iconColor: Theme.of(context).colorScheme.primary, size: 36, onPressed: () { diff --git a/lib/new-ui/widgets/coins_page/wallet_info.dart b/lib/new-ui/widgets/coins_page/wallet_info.dart index b7478abce..469ff630c 100644 --- a/lib/new-ui/widgets/coins_page/wallet_info.dart +++ b/lib/new-ui/widgets/coins_page/wallet_info.dart @@ -49,6 +49,7 @@ class WalletInfoBar extends StatelessWidget { Expanded( child: Text( name, + key: const ValueKey("home_page_wallet_name_text_key"), maxLines: 1, softWrap: false, overflow: TextOverflow.ellipsis, diff --git a/lib/new-ui/widgets/line_tab_switcher.dart b/lib/new-ui/widgets/line_tab_switcher.dart index 9b7ebf09d..4e894a589 100644 --- a/lib/new-ui/widgets/line_tab_switcher.dart +++ b/lib/new-ui/widgets/line_tab_switcher.dart @@ -101,6 +101,7 @@ class _LineTabSwitcherState extends State { selected: widget.selectedTab == index, inMutuallyExclusiveGroup: true, child: GestureDetector( + key: ValueKey("line_tab_switcher_${index}_key"), onTap: () { widget.onTabChange(index); }, diff --git a/lib/new-ui/widgets/receive_page/receive_address_widget.dart b/lib/new-ui/widgets/receive_page/receive_address_widget.dart index 1584d411c..a327b7038 100644 --- a/lib/new-ui/widgets/receive_page/receive_address_widget.dart +++ b/lib/new-ui/widgets/receive_page/receive_address_widget.dart @@ -11,6 +11,7 @@ class ReceiveAddressWidget extends StatelessWidget { @override Widget build(BuildContext context) { return Padding( + key: const ValueKey("receive_page_address_key"), padding: const EdgeInsets.symmetric(horizontal: 50.0), child: Observer( builder: (_) => AddressFormatter.buildSegmentedAddress( diff --git a/lib/new-ui/widgets/send_page/send_address_input.dart b/lib/new-ui/widgets/send_page/send_address_input.dart index e7ff361b8..c8c8d57e1 100644 --- a/lib/new-ui/widgets/send_page/send_address_input.dart +++ b/lib/new-ui/widgets/send_page/send_address_input.dart @@ -148,6 +148,7 @@ class _NewSendAddressInputState extends State { _presentQRScanner(context); }), FloatingIconButton( + key: ValueKey("send_page_address_book_button_key"), iconPath: "assets/new-ui/contacts_outlined.svg", semanticLabel: S.of(context).address_book, onPressed: () { diff --git a/lib/new-ui/widgets/send_page/send_confirm_bottom_widget.dart b/lib/new-ui/widgets/send_page/send_confirm_bottom_widget.dart index d09149569..5c9b076a2 100644 --- a/lib/new-ui/widgets/send_page/send_confirm_bottom_widget.dart +++ b/lib/new-ui/widgets/send_page/send_confirm_bottom_widget.dart @@ -46,6 +46,7 @@ class SendConfirmBottomWidget extends StatelessWidget { switch (state) { case ExecutedSuccessfullyState: return ConfirmSwiper( + key: ValueKey("send_page_confirm_swiper_key"), onConfirmed: () { sendViewModel.commitTransaction(context); }, diff --git a/lib/new-ui/widgets/swap_page/swap_amount_box.dart b/lib/new-ui/widgets/swap_page/swap_amount_box.dart index 784dbd4ef..a7f058193 100644 --- a/lib/new-ui/widgets/swap_page/swap_amount_box.dart +++ b/lib/new-ui/widgets/swap_page/swap_amount_box.dart @@ -211,6 +211,9 @@ class SwapAmountBoxState extends State { widget.exchangeViewModel.receiveAmount.isNotEmpty && widget.exchangeViewModel.depositAmount.isEmpty; return TextField( + key: ValueKey(widget.isReceiverCard + ? "swap_page_receive_amount_field_key" + : "swap_page_deposit_amount_field_key"), keyboardType: TextInputType.numberWithOptions( signed: false, decimal: !widget.useBaseUnit, @@ -260,6 +263,9 @@ class SwapAmountBoxState extends State { ), ), GestureDetector( + key: ValueKey(widget.isReceiverCard + ? "swap_amount_box_receive_currency_button_key" + : "swap_amount_box_deposit_currency_button_key"), onTap: _presentCurrencyPicker, child: Container( decoration: BoxDecoration( diff --git a/lib/src/screens/contact/contact_list_page.dart b/lib/src/screens/contact/contact_list_page.dart index 6e988384e..61fa3f38a 100644 --- a/lib/src/screens/contact/contact_list_page.dart +++ b/lib/src/screens/contact/contact_list_page.dart @@ -68,6 +68,7 @@ class ContactListPage extends BasePage implements PageOpenListener { minWidth: 32.0, height: 32.0, child: TextButton( + key: ValueKey("contact_list_page_add_contact_button_key"), // FIX-ME: Style //shape: CircleBorder(), onPressed: () async { diff --git a/lib/src/screens/contact/contact_page.dart b/lib/src/screens/contact/contact_page.dart index c1c7337d8..abfbca1bd 100644 --- a/lib/src/screens/contact/contact_page.dart +++ b/lib/src/screens/contact/contact_page.dart @@ -73,6 +73,7 @@ class ContactPage extends BasePage { mainAxisSize: MainAxisSize.min, children: [ BaseTextFormField( + key: ValueKey("contact_page_name_textfield_key"), controller: _nameController, hintText: S.of(context).contact_name, validator: ContactNameValidator(), @@ -81,6 +82,7 @@ class ContactPage extends BasePage { padding: EdgeInsets.only(top: 20), child: Container( child: InkWell( + key: ValueKey("contact_page_currency_picker_button_key"), onTap: () => _presentCurrencyPicker(context), child: IgnorePointer( child: BaseTextFormField( @@ -105,6 +107,7 @@ class ContactPage extends BasePage { child: Observer( builder: (_) { return NewSendAddressInput( + key: ValueKey("contact_page_address_input_key"), focusNode: addressFocusNode, displayName: contactViewModel.displayName, addressController: _addressController, @@ -138,6 +141,7 @@ class ContactPage extends BasePage { Expanded( child: Observer( builder: (_) => PrimaryButton( + key: ValueKey("contact_page_save_button_key"), onPressed: () async { FocusScope.of(context).unfocus(); _extractParsedAddress(context); diff --git a/lib/src/screens/dashboard/favorite_token_modal.dart b/lib/src/screens/dashboard/favorite_token_modal.dart index 1b0c9ac13..19df21071 100644 --- a/lib/src/screens/dashboard/favorite_token_modal.dart +++ b/lib/src/screens/dashboard/favorite_token_modal.dart @@ -39,11 +39,13 @@ class FavoriteTokenModal extends StatelessWidget { child: NewListSections( sections: { "": tokens - .map((item) => ListItemRegularRow( - keyValue: item.title, - label: "${item.fullName} (${item.title})", - iconPath: item.iconPath, - onTap: () => Navigator.of(context).pop(item))) + .asMap() + .entries + .map((entry) => ListItemRegularRow( + keyValue: "${entry.value.title}_${entry.key}", + label: "${entry.value.fullName} (${entry.value.title})", + iconPath: entry.value.iconPath, + onTap: () => Navigator.of(context).pop(entry.value))) .toList() }, ), diff --git a/lib/src/screens/dashboard/widgets/new_main_navbar_widget.dart b/lib/src/screens/dashboard/widgets/new_main_navbar_widget.dart index a1b3b6697..d3b4e4976 100644 --- a/lib/src/screens/dashboard/widgets/new_main_navbar_widget.dart +++ b/lib/src/screens/dashboard/widgets/new_main_navbar_widget.dart @@ -193,6 +193,7 @@ class _NEWNewMainNavBarState extends State { inMutuallyExclusiveGroup: true, label: visibleActions[i].name(context), child: InkWell( + key: visibleActions[i].key, splashFactory: NoSplash.splashFactory, splashColor: Colors.transparent, borderRadius: BorderRadius.circular(pillBorderRadius), diff --git a/lib/src/screens/new_wallet/wallet_group_display_page.dart b/lib/src/screens/new_wallet/wallet_group_display_page.dart index 0b8f34626..5e1976820 100644 --- a/lib/src/screens/new_wallet/wallet_group_display_page.dart +++ b/lib/src/screens/new_wallet/wallet_group_display_page.dart @@ -114,6 +114,7 @@ class WalletGroupsDisplayBody extends StatelessWidget { Observer( builder: (context) { return LoadingPrimaryButton( + key: ValueKey("wallet_group_display_page_next_button_key"), isLoading: walletGroupsDisplayViewModel.isFetchingMnemonic, onPressed: () { if (walletGroupsDisplayViewModel.hasNoFilteredWallet) { diff --git a/lib/src/screens/restore/wallet_restore_page.dart b/lib/src/screens/restore/wallet_restore_page.dart index af8e9b91e..6970b857e 100644 --- a/lib/src/screens/restore/wallet_restore_page.dart +++ b/lib/src/screens/restore/wallet_restore_page.dart @@ -102,6 +102,7 @@ class WalletRestorePage extends BasePage { builder: (context) { return walletRestoreViewModel.passphraseAvailable ? StandardCheckbox( + key: const ValueKey("wallet_restore_has_passphrase_checkbox_key"), captionColor: Theme.of(context).colorScheme.onSecondaryContainer, value: walletRestoreViewModel.hasPassphrase, caption: S.of(context).wallet_has_passphrase, diff --git a/lib/src/screens/wallet/wallet_edit_page.dart b/lib/src/screens/wallet/wallet_edit_page.dart index 8e8fc795a..88222ba4d 100644 --- a/lib/src/screens/wallet/wallet_edit_page.dart +++ b/lib/src/screens/wallet/wallet_edit_page.dart @@ -56,6 +56,7 @@ class WalletEditPage extends BasePage { Expanded( child: Center( child: BaseTextFormField( + key: ValueKey("wallet_edit_page_name_input_key"), controller: _labelController, hintText: S.of(context).wallet_list_wallet_name, validator: WalletNameValidator(), @@ -74,6 +75,7 @@ class WalletEditPage extends BasePage { child: Container( padding: EdgeInsets.only(right: 8.0), child: LoadingPrimaryButton( + key: ValueKey("wallet_edit_page_delete_button_key"), isDisabled: isLoading, onPressed: () => _removeWallet(context), text: S.of(context).delete, @@ -86,6 +88,7 @@ class WalletEditPage extends BasePage { child: Container( padding: EdgeInsets.only(left: 8.0), child: LoadingPrimaryButton( + key: ValueKey("wallet_edit_page_save_button_key"), onPressed: () async { if (_formKey.currentState?.validate() ?? false) { if (!pageArguments.isWalletGroup && @@ -95,6 +98,8 @@ class WalletEditPage extends BasePage { context: context, builder: (_) { return AlertWithOneAction( + buttonKey: + ValueKey("wallet_edit_page_name_taken_ok_button_key"), alertTitle: '', alertContent: S.of(context).wallet_name_exists, buttonText: S.of(context).ok, @@ -182,6 +187,8 @@ class WalletEditPage extends BasePage { context: context, builder: (BuildContext dialogContext) { return AlertWithTwoActions( + alertRightActionButtonKey: ValueKey("wallet_edit_page_confirm_delete_button_key"), + alertLeftActionButtonKey: ValueKey("wallet_edit_page_cancel_delete_button_key"), alertTitle: S.of(context).delete_wallet, alertContent: S.of(context).delete_wallet_confirm_message(pageArguments.editingWallet.name), diff --git a/lib/src/screens/wallet_list/wallet_list_page.dart b/lib/src/screens/wallet_list/wallet_list_page.dart index f9ad4b180..b8fb25956 100644 --- a/lib/src/screens/wallet_list/wallet_list_page.dart +++ b/lib/src/screens/wallet_list/wallet_list_page.dart @@ -231,6 +231,7 @@ class WalletListBodyState extends State { return item.isCurrent ? SizedBox.shrink() : EditWalletButtonWidget( + key: ValueKey("wallet_list_edit_${item.name}_button_key"), width: 64, onTap: () => Navigator.of(context).pushNamed( Routes.walletEdit, @@ -314,6 +315,7 @@ class WalletListBodyState extends State { trailingWidget: wallet.isCurrent ? null : EditWalletButtonWidget( + key: ValueKey("wallet_list_edit_${wallet.name}_button_key"), width: 64, onTap: () { Navigator.of(context).pushNamed( diff --git a/lib/src/widgets/alert_with_one_action.dart b/lib/src/widgets/alert_with_one_action.dart index de6c0a870..95d819d93 100644 --- a/lib/src/widgets/alert_with_one_action.dart +++ b/lib/src/widgets/alert_with_one_action.dart @@ -47,31 +47,6 @@ class AlertWithOneAction extends BaseAlertDialog { @override String get rightActionButtonText => buttonText; - // @override - // Widget actionButtons(BuildContext context) { - // return Container( - // padding: EdgeInsets.only(left: 12, right: 12), - // color: Theme.of(context).colorScheme.surface, - // child: ButtonTheme( - // minWidth: double.infinity, - // child: TextButton( - // key: buttonKey, - // onPressed: buttonAction, - // // FIX-ME: Style - // //highlightColor: Colors.transparent, - // //splashColor: Colors.transparent, - // child: Text( - // buttonText, - // textAlign: TextAlign.center, - // style: Theme.of(context).textTheme.bodyMedium!.copyWith( - // fontSize: 15, - // fontWeight: FontWeight.w600, - // color: Theme.of(context).colorScheme.primary, - // decoration: TextDecoration.none, - // ), - // ), - // ), - // ), - // ); - // } + @override + Key? get rightActionButtonKey => buttonKey; } diff --git a/lib/src/widgets/new_list_row/new_list_section.dart b/lib/src/widgets/new_list_row/new_list_section.dart index c4f4e9201..8be05696c 100644 --- a/lib/src/widgets/new_list_row/new_list_section.dart +++ b/lib/src/widgets/new_list_row/new_list_section.dart @@ -90,6 +90,7 @@ class NewListSections extends StatelessWidget { if (item is ListItemRegularRow) { return ListItemRegularRowWidget( + key: ValueKey(item.keyValue), keyValue: item.keyValue, label: item.label, subtitle: item.subtitle, @@ -118,6 +119,7 @@ class NewListSections extends StatelessWidget { if (item is ListItemToggle) { return ListItemToggleWidget( + key: ValueKey(item.keyValue), keyValue: item.keyValue, label: item.label, subtitle: item.subtitle, @@ -132,6 +134,7 @@ class NewListSections extends StatelessWidget { if (item is ListItemCheckbox) { return ListItemCheckboxWidget( + key: ValueKey(item.keyValue), keyValue: item.keyValue, label: item.label, subtitle: item.subtitle, @@ -148,6 +151,7 @@ class NewListSections extends StatelessWidget { if (item is ListItemDropdown) { return ListItemDropdownWidget( + key: ValueKey(item.keyValue), keyValue: item.keyValue, label: item.label, trailingText: item.trailingText, @@ -159,6 +163,7 @@ class NewListSections extends StatelessWidget { if (item is ListItemSelector) { return ListItemSelectorWidget( + key: ValueKey(item.keyValue), keyValue: item.keyValue, label: item.label, options: item.options, diff --git a/lib/src/widgets/picker.dart b/lib/src/widgets/picker.dart index 117c15d95..1695216bd 100644 --- a/lib/src/widgets/picker.dart +++ b/lib/src/widgets/picker.dart @@ -2,13 +2,11 @@ import 'dart:math'; -import 'package:cake_wallet/entities/seed_type.dart'; import 'package:cake_wallet/new-ui/widgets/coins_page/token_image_widget.dart'; import 'package:cake_wallet/src/widgets/search_bar_widget.dart'; import 'package:cake_wallet/themes/core/theme_extension.dart'; import 'package:cake_wallet/utils/responsive_layout_util.dart'; import 'package:cw_core/crypto_currency.dart'; -import 'package:cw_core/transaction_priority.dart'; import 'package:flutter/material.dart'; import 'package:cw_core/currency.dart'; import 'package:cake_wallet/src/widgets/picker_wrapper_widget.dart'; @@ -304,20 +302,7 @@ class _PickerState extends State> { ); } - String _getItemName(Item item) { - String itemName; - if (item is Currency) { - itemName = item.name; - } else if (item is TransactionPriority) { - itemName = item.title; - } else if (item is MoneroSeedType) { - itemName = item.title; - } else { - itemName = ''; - } - - return itemName; - } + String _getItemName(Item item) => item is Currency ? item.name : item.toString(); Widget buildItem(int index) { final item = widget.headerEnabled ? filteredItems[index] : items[index]; diff --git a/lib/src/widgets/standard_checkbox.dart b/lib/src/widgets/standard_checkbox.dart index d67e368ac..0379fe21a 100644 --- a/lib/src/widgets/standard_checkbox.dart +++ b/lib/src/widgets/standard_checkbox.dart @@ -2,7 +2,8 @@ import 'package:flutter/material.dart'; class StandardCheckbox extends StatelessWidget { StandardCheckbox( - {required this.value, + {super.key, + required this.value, this.caption = '', this.gradientBackground = false, this.borderColor, diff --git a/scripts/ci/emulator_ready.sh b/scripts/ci/emulator_ready.sh new file mode 100644 index 000000000..e46b8f006 --- /dev/null +++ b/scripts/ci/emulator_ready.sh @@ -0,0 +1,12 @@ +#!/bin/bash + +echo "Waiting for boot completion..." +timeout 300 bash -c 'until adb shell getprop sys.boot_completed 2>/dev/null | grep -q "1"; do sleep 5; done' || echo "Boot completion timeout, continuing" + +echo "Waiting for package manager..." +timeout 60 bash -c 'until adb shell pm list packages 2>/dev/null >/dev/null; do sleep 2; done' || echo "Package manager timeout, continuing" + +echo "Waiting for settings service..." +timeout 60 bash -c 'until adb shell settings get global window_animation_scale 2>/dev/null >/dev/null; do sleep 2; done' || echo "Settings service timeout, continuing" + +echo "Emulator readiness check completed" diff --git a/scripts/ci/run_integration_tests.sh b/scripts/ci/run_integration_tests.sh new file mode 100644 index 000000000..f41989494 --- /dev/null +++ b/scripts/ci/run_integration_tests.sh @@ -0,0 +1,115 @@ +#!/bin/bash + +TIER="$1" + +if [[ -z "${SUITE_DIR_INPUT:-}" ]]; then + echo "SUITE_DIR_INPUT never reached the emulator script" + exit 1 +fi + +KEYS_IMPORTED="" + +import_encryption_keys() { + if [[ -n "$KEYS_IMPORTED" ]]; then + return + fi + + gpg --keyserver hkps://keyserver.ubuntu.com --recv-keys 6B3199AD9B3D23B8 || true # konstantin@cakewallet.com + gpg --keyserver hkps://keyserver.ubuntu.com --recv-keys 35C8DBAFB8D9ACAC || true # cyjan@mrcyjanek.net + gpg --keyserver hkps://keyserver.ubuntu.com --recv-keys 0126DFF8D54495EB || true # david@cakewallet.com + + if gpg --list-keys 6B3199AD9B3D23B8 > /dev/null 2>&1 && + gpg --list-keys 35C8DBAFB8D9ACAC > /dev/null 2>&1 && + gpg --list-keys 0126DFF8D54495EB > /dev/null 2>&1; then + KEYS_IMPORTED=1 + else + echo "Recipient keys missing from the keyring, the next call retries the import" + fi +} + +encrypt_and_drop() { + gpg --trust-model always --encrypt --output "$1.gpg" \ + --recipient 6B3199AD9B3D23B8 \ + --recipient 35C8DBAFB8D9ACAC \ + --recipient 0126DFF8D54495EB \ + "$1" || echo "Encrypting $1 failed, discarding it" + rm -f "$1" +} + +bash scripts/ci/emulator_ready.sh + +adb shell settings put global window_animation_scale 0.0 || true +adb shell settings put global transition_animation_scale 0.0 || true +adb shell settings put global animator_duration_scale 0.0 || true + +echo "===== resources before $TIER =====" +free -h || true +df -h / || true + +if [[ "$RECORD_SCREEN" == "true" ]]; then + echo "Starting chunked screen recording..." + (i=0; while true; do adb shell screenrecord --time-limit 180 --bit-rate 2000000 "/sdcard/rec_${TIER}_$i.mp4" || break; i=$((i+1)); done) & + RECORD_LOOP_PID=$! +fi + +# Both tiers get one retry. tier1 talks to live nodes and providers, and giving it +# no retry meant a driver that never attached was reported as a failing test. +RETRIES=1 + +# The step log is public, so a run log that gets encrypted below must not print here. +run_suites() { + env \ + SUITE_DIR="$SUITE_DIR_INPUT" \ + TEST_TIER="$TIER" \ + EXTRA_DART_DEFINES="$EXTRA_DART_DEFINES_INPUT" \ + TEST_TIMEOUT=600 \ + RETRY_COUNT=$RETRIES \ + PLATFORM=android \ + SUMMARY_FILE="${TIER}_summary.txt" \ + ./integration_test_runner.sh 2>&1 +} + +set -o pipefail +set +e +if [[ "${ENCRYPT_LOGS:-}" == "true" ]]; then + run_suites > "${TIER}_run.log" +else + run_suites | tee "${TIER}_run.log" +fi +TEST_EXIT_CODE=$? +set -e + +echo "===== resources after $TIER =====" +free -h || true +df -h / || true + +adb logcat -d > "${TIER}_logcat.txt" || true + +# A funds run puts real funded seeds on the device and these carry whatever the app +# logs, so they leave the runner the same way the recordings do +if [[ "${ENCRYPT_LOGS:-}" == "true" ]]; then + import_encryption_keys + encrypt_and_drop "${TIER}_logcat.txt" + encrypt_and_drop "${TIER}_run.log" +fi + +if [[ -n "${RECORD_LOOP_PID:-}" ]]; then + echo "Stopping screen recording and encrypting..." + kill "$RECORD_LOOP_PID" || true + adb shell killall -INT screenrecord || true + sleep 5 + + mkdir -p recordings + for f in $(adb shell ls /sdcard/rec_*.mp4 2>/dev/null | tr -d '\r'); do + adb pull "$f" recordings/ || true + done + + # Recordings show seed words on screen so they are never uploaded in plaintext + import_encryption_keys + for f in recordings/*.mp4; do + [[ -f "$f" ]] || continue + encrypt_and_drop "$f" + done +fi + +exit $TEST_EXIT_CODE diff --git a/test/widget_test.dart b/test/widget_test.dart deleted file mode 100644 index c79ce99ad..000000000 --- a/test/widget_test.dart +++ /dev/null @@ -1,34 +0,0 @@ -// This is a basic Flutter widget test. -// -// To perform an interaction with a widget in your test, use the WidgetTester -// utility that Flutter provides. For example, you can send tap and scroll -// gestures. You can also use WidgetTester to find child widgets in the widget -// tree, read text, and verify that the values of widget properties are correct. - -import 'dart:async'; - -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; - -import 'package:cake_wallet/main.dart'; - -final quickActionsStream = StreamController.broadcast(); - -void main() { - testWidgets('Counter increments smoke test', (WidgetTester tester) async { - // Build our app and trigger a frame. - await tester.pumpWidget(App(quickActionsStream: quickActionsStream.stream)); - - // Verify that our counter starts at 0. - expect(find.text('0'), findsOneWidget); - expect(find.text('1'), findsNothing); - - // Tap the '+' icon and trigger a frame. - await tester.tap(find.byIcon(Icons.add)); - await tester.pump(); - - // Verify that our counter has incremented. - expect(find.text('0'), findsNothing); - expect(find.text('1'), findsOneWidget); - }); -} diff --git a/test_driver/integration_test.dart b/test_driver/integration_test.dart index 2e67d866f..045b33472 100644 --- a/test_driver/integration_test.dart +++ b/test_driver/integration_test.dart @@ -1,19 +1,40 @@ -import 'dart:convert'; +import "dart:async"; +import "dart:convert"; -import 'package:integration_test/integration_test_driver.dart'; -import 'package:path/path.dart' as path; - -import 'package:flutter_driver/flutter_driver.dart'; +import "package:flutter_driver/flutter_driver.dart"; +import "package:integration_test/integration_test_driver_extended.dart"; +import "package:path/path.dart" as path; Future main() async { - integrationDriver( - responseDataCallback: (Map? data) async { + await runZoned( + _drive, + zoneSpecification: ZoneSpecification( + print: (self, parent, zone, line) { + if (!line.startsWith("result {")) { + parent.print(zone, line); + } + }, + ), + ); +} + +Future _drive() async { + await integrationDriver( + onScreenshot: (name, bytes, [args]) async { + await fs.directory(_screenshotDirectory).create(recursive: true); + await fs.file(path.join(_screenshotDirectory, "$name.png")).writeAsBytes(bytes); + + return true; + }, + responseDataCallback: (data) async { + data?.remove("screenshots"); + await fs.directory(_destinationDirectory).create(recursive: true); final file = fs.file( path.join( _destinationDirectory, - '$_testOutputFilename.json', + "$_testOutputFilename.json", ), ); @@ -28,6 +49,7 @@ String _encodeJson(Map? jsonObject) { return _prettyEncoder.convert(jsonObject); } -const _prettyEncoder = JsonEncoder.withIndent(' '); -const _testOutputFilename = 'integration_response_data'; -const _destinationDirectory = 'integration_test'; +const _prettyEncoder = JsonEncoder.withIndent(" "); +const _testOutputFilename = "integration_response_data"; +const _destinationDirectory = "build/integration_test"; +const _screenshotDirectory = "build/integration_test_screenshots"; diff --git a/tool/utils/secret_key.dart b/tool/utils/secret_key.dart index d4024cc51..6128637df 100644 --- a/tool/utils/secret_key.dart +++ b/tool/utils/secret_key.dart @@ -50,7 +50,7 @@ class SecretKey { SecretKey('meldTestApiKey', () => ''), SecretKey('meldTestPublicKey', () => ''), SecretKey('moneroTestWalletSeeds', () => ''), - SecretKey('moneroLegacyTestWalletSeeds ', () => ''), + SecretKey('moneroLegacyTestWalletSeeds', () => ''), SecretKey('bitcoinTestWalletSeeds', () => ''), SecretKey('ethereumTestWalletSeeds', () => ''), SecretKey('litecoinTestWalletSeeds', () => ''), @@ -60,9 +60,14 @@ class SecretKey { SecretKey('arbitrumTestWalletSeeds', () => ''), SecretKey('bscTestWalletSeeds', () => ''), SecretKey('solanaTestWalletSeeds', () => ''), + SecretKey('solanaTestWalletSeeds2', () => ''), SecretKey('tronTestWalletSeeds', () => ''), SecretKey('nanoTestWalletSeeds', () => ''), SecretKey('wowneroTestWalletSeeds', () => ''), + SecretKey('zanoTestWalletSeeds', () => ''), + SecretKey('decredTestWalletSeeds', () => ''), + SecretKey('dogeTestWalletSeeds', () => ''), + SecretKey('zcashTestWalletSeeds', () => ''), SecretKey('moneroTestWalletReceiveAddress', () => ''), SecretKey('bitcoinTestWalletReceiveAddress', () => ''), SecretKey('ethereumTestWalletReceiveAddress', () => ''), @@ -76,6 +81,10 @@ class SecretKey { SecretKey('tronTestWalletReceiveAddress', () => ''), SecretKey('nanoTestWalletReceiveAddress', () => ''), SecretKey('wowneroTestWalletReceiveAddress', () => ''), + SecretKey('zanoTestWalletReceiveAddress', () => ''), + SecretKey('decredTestWalletReceiveAddress', () => ''), + SecretKey('dogeTestWalletReceiveAddress', () => ''), + SecretKey('zcashTestWalletReceiveAddress', () => ''), SecretKey('etherScanApiKey', () => ''), SecretKey('polygonScanApiKey', () => ''), SecretKey('letsExchangeBearerToken', () => ''), @@ -83,6 +92,7 @@ class SecretKey { SecretKey('stealthExBearerToken', () => ''), SecretKey('stealthExAdditionalFeePercent', () => ''), SecretKey('moneroTestWalletBlockHeight', () => ''), + SecretKey('zcashTestWalletRestoreHeight', () => ''), SecretKey('chainflipApiKey', () => ''), SecretKey('chainflipAffiliateFee', () => ''), SecretKey('kryptonimApiKey', () => ''), @@ -100,7 +110,7 @@ class SecretKey { SecretKey('etherScanApiKey', () => ''), SecretKey('polygonScanApiKey', () => ''), SecretKey('moralisApiKey', () => ''), - SecretKey('nowNodesApiKey ', () => ''), + SecretKey('nowNodesApiKey', () => ''), SecretKey('blinkApiKey', () => ''), ];