ci(e2e): run iOS and Android Maestro tests in parallel #51
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: E2E Tests | |
| on: | |
| pull_request: | |
| push: | |
| branches: [main] | |
| jobs: | |
| changes: | |
| runs-on: ubuntu-latest | |
| outputs: | |
| run_e2e: ${{ steps.filter.outputs.e2e }} | |
| steps: | |
| - uses: actions/checkout@v4 | |
| with: | |
| fetch-depth: 0 | |
| - id: filter | |
| uses: dorny/paths-filter@v3 | |
| with: | |
| filters: | | |
| e2e: | |
| - "src/**" | |
| - "e2e/**" | |
| - "example/app/**" | |
| - "package.json" | |
| - "yarn.lock" | |
| - ".yarnrc.yml" | |
| - ".yarn/**" | |
| - "babel.config.js" | |
| - "tsconfig.json" | |
| - ".github/workflows/e2e.yaml" | |
| - name: Show E2E decision | |
| run: echo "run_e2e=${{ steps.filter.outputs.e2e }}" | |
| e2e-ios: | |
| needs: changes | |
| if: needs.changes.outputs.run_e2e == 'true' | |
| runs-on: macos-15 | |
| timeout-minutes: 90 | |
| env: | |
| E2E_APP_ID: com.react-native-reanimated-carousel.example | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - name: Select Xcode | |
| uses: maxim-lobanov/setup-xcode@v1 | |
| with: | |
| xcode-version: "latest-stable" | |
| - name: Show Xcode version | |
| id: xcode-version | |
| run: | | |
| set -euxo pipefail | |
| xcodebuild -version | |
| XCODE_VERSION=$(xcodebuild -version | tr '\n' ' ' | sed 's/ */-/g; s/[^A-Za-z0-9._-]/-/g') | |
| echo "version=${XCODE_VERSION}" >> "$GITHUB_OUTPUT" | |
| - uses: actions/setup-node@v4 | |
| with: | |
| node-version: 22 | |
| cache: yarn | |
| - name: Install dependencies | |
| run: | | |
| set -euxo pipefail | |
| retry() { | |
| local max_retries="$1" | |
| shift | |
| local attempt=1 | |
| until "$@"; do | |
| if [ "$attempt" -ge "$max_retries" ]; then | |
| echo "Command failed after ${attempt} attempts: $*" | |
| return 1 | |
| fi | |
| attempt=$((attempt + 1)) | |
| echo "Retrying (${attempt}/${max_retries}): $*" | |
| sleep 10 | |
| done | |
| } | |
| retry 3 yarn install --frozen-lockfile | |
| cd example/app | |
| retry 3 yarn install --frozen-lockfile | |
| - name: Setup Java (required by Maestro) | |
| uses: actions/setup-java@v4 | |
| with: | |
| distribution: temurin | |
| java-version: 17 | |
| - name: Install Maestro | |
| run: | | |
| curl -Ls "https://get.maestro.mobile.dev" | bash | |
| echo "$HOME/.maestro/bin" >> "$GITHUB_PATH" | |
| - name: Boot iOS Simulator | |
| run: | | |
| DEVICE_NAME="iPhone 16" | |
| DEVICE_UDID=$(xcrun simctl list devices available -j | python3 -c " | |
| import json, sys | |
| data = json.load(sys.stdin) | |
| for runtime, devices in data['devices'].items(): | |
| if 'iOS' in runtime: | |
| for d in devices: | |
| if '${DEVICE_NAME}' in d['name'] and d['isAvailable']: | |
| print(d['udid']) | |
| sys.exit(0) | |
| sys.exit(1) | |
| ") | |
| xcrun simctl boot "$DEVICE_UDID" | |
| echo "SIMULATOR_UDID=$DEVICE_UDID" >> "$GITHUB_ENV" | |
| echo "SIMULATOR_NAME=$DEVICE_NAME" >> "$GITHUB_ENV" | |
| - name: Restore CocoaPods download cache | |
| id: cocoapods-cache | |
| uses: actions/cache/restore@v4 | |
| with: | |
| path: | | |
| ~/Library/Caches/CocoaPods | |
| ~/.cocoapods/repos | |
| key: ${{ runner.os }}-${{ steps.xcode-version.outputs.version }}-cocoapods-${{ hashFiles('example/app/yarn.lock', 'example/app/package.json') }} | |
| restore-keys: | | |
| ${{ runner.os }}-${{ steps.xcode-version.outputs.version }}-cocoapods- | |
| - name: Prebuild iOS | |
| working-directory: example/app | |
| run: npx expo prebuild --platform ios --non-interactive | |
| - name: Save CocoaPods download cache | |
| if: always() && steps.cocoapods-cache.outputs.cache-hit != 'true' | |
| uses: actions/cache/save@v4 | |
| with: | |
| path: | | |
| ~/Library/Caches/CocoaPods | |
| ~/.cocoapods/repos | |
| key: ${{ runner.os }}-${{ steps.xcode-version.outputs.version }}-cocoapods-${{ hashFiles('example/app/yarn.lock', 'example/app/package.json') }} | |
| - name: Verify generated iOS workspace | |
| run: test -d example/app/ios/app.xcworkspace | |
| - name: Restore iOS app build cache | |
| id: restore-ios-build-cache | |
| uses: actions/cache/restore@v4 | |
| with: | |
| path: example/app/ios/build | |
| key: ${{ runner.os }}-${{ steps.xcode-version.outputs.version }}-ios-build-${{ hashFiles('yarn.lock', 'example/app/yarn.lock', 'example/app/package.json', 'example/app/app.json', 'example/app/ios/Podfile.lock', 'example/app/ios/**/*.pbxproj') }} | |
| restore-keys: | | |
| ${{ runner.os }}-${{ steps.xcode-version.outputs.version }}-ios-build- | |
| - name: Check cached app bundle | |
| id: cached-app | |
| run: | | |
| if [ -d example/app/ios/build/Build/Products/Debug-iphonesimulator/app.app ]; then | |
| echo "exists=true" >> "$GITHUB_OUTPUT" | |
| else | |
| echo "exists=false" >> "$GITHUB_OUTPUT" | |
| fi | |
| - name: Build for iOS Simulator | |
| if: steps.cached-app.outputs.exists != 'true' | |
| working-directory: example/app/ios | |
| run: | | |
| set -o pipefail | |
| xcodebuild \ | |
| -workspace app.xcworkspace \ | |
| -scheme app \ | |
| -configuration Debug \ | |
| -sdk iphonesimulator \ | |
| -destination "platform=iOS Simulator,name=${SIMULATOR_NAME}" \ | |
| -derivedDataPath build \ | |
| build | tee xcodebuild.log | |
| - name: Check built app bundle after build step | |
| if: always() | |
| id: built-app-after-build | |
| run: | | |
| if [ -d example/app/ios/build/Build/Products/Debug-iphonesimulator/app.app ]; then | |
| echo "exists=true" >> "$GITHUB_OUTPUT" | |
| else | |
| echo "exists=false" >> "$GITHUB_OUTPUT" | |
| fi | |
| - name: Save iOS app build cache | |
| if: always() && steps.restore-ios-build-cache.outputs.cache-hit != 'true' && steps.built-app-after-build.outputs.exists == 'true' | |
| uses: actions/cache/save@v4 | |
| with: | |
| path: example/app/ios/build | |
| key: ${{ runner.os }}-${{ steps.xcode-version.outputs.version }}-ios-build-${{ hashFiles('yarn.lock', 'example/app/yarn.lock', 'example/app/package.json', 'example/app/app.json', 'example/app/ios/Podfile.lock', 'example/app/ios/**/*.pbxproj') }} | |
| - name: Reuse cached iOS app build | |
| if: steps.cached-app.outputs.exists == 'true' | |
| run: | | |
| echo "Using cached app bundle, skipping xcodebuild." | |
| ls -lah example/app/ios/build/Build/Products/Debug-iphonesimulator/app.app | |
| - name: Install app on Simulator | |
| run: | | |
| xcrun simctl install booted \ | |
| example/app/ios/build/Build/Products/Debug-iphonesimulator/app.app | |
| - name: Run E2E tests | |
| timeout-minutes: 35 | |
| working-directory: example/app | |
| run: | | |
| set -euxo pipefail | |
| npx expo start --port 8081 > /tmp/metro.log 2>&1 & | |
| METRO_PID=$! | |
| trap 'kill "$METRO_PID" || true; echo "::group::Metro logs"; tail -n 200 /tmp/metro.log || true; echo "::endgroup::"' EXIT | |
| for i in $(seq 1 60); do | |
| if curl -s http://localhost:8081/status 2>/dev/null | grep -q "packager-status:running"; then | |
| echo "Metro is ready" | |
| break | |
| fi | |
| if [ "$i" -eq 60 ]; then | |
| echo "Metro failed to start in time" | |
| exit 1 | |
| fi | |
| echo "Waiting for Metro... ($i/60)" | |
| sleep 2 | |
| done | |
| BUNDLE_URL='http://127.0.0.1:8081/node_modules/expo-router/entry.bundle?platform=ios&dev=true&hot=false&lazy=true&transform.engine=hermes&transform.bytecode=1&transform.routerRoot=app&unstable_transformProfile=hermes-stable' | |
| for i in $(seq 1 60); do | |
| if curl -sf "$BUNDLE_URL" -o /dev/null; then | |
| echo "iOS bundle is ready" | |
| break | |
| fi | |
| if [ "$i" -eq 60 ]; then | |
| echo "Timed out waiting for iOS bundle" | |
| exit 1 | |
| fi | |
| echo "Waiting for iOS bundle... ($i/60)" | |
| sleep 2 | |
| done | |
| xcrun simctl launch booted "$E2E_APP_ID" | |
| sleep 10 | |
| MAESTRO_FLOW_TIMEOUT_SECONDS=360 \ | |
| MAESTRO_FLOW_MAX_ATTEMPTS=2 \ | |
| MAESTRO_FAIL_FAST=1 \ | |
| bash "$GITHUB_WORKSPACE/scripts/e2e-maestro-suite.sh" "$GITHUB_WORKSPACE/e2e" | |
| - name: Collect iOS debug artifacts | |
| if: always() | |
| run: | | |
| set -euxo pipefail | |
| mkdir -p /tmp/e2e-debug | |
| cp -f /tmp/metro.log /tmp/e2e-debug/metro.log || true | |
| cp -f /tmp/maestro.log /tmp/e2e-debug/maestro.log || true | |
| cp -R /tmp/maestro-flow-logs /tmp/e2e-debug/maestro-flow-logs || true | |
| cp -f example/app/ios/xcodebuild.log /tmp/e2e-debug/xcodebuild.log || true | |
| xcrun simctl list devices > /tmp/e2e-debug/simctl-devices.txt || true | |
| xcrun simctl io booted screenshot /tmp/e2e-debug/final-simulator-screen.png || true | |
| - name: Upload iOS test artifacts | |
| if: always() | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: e2e-results-ios | |
| if-no-files-found: warn | |
| retention-days: 7 | |
| path: | | |
| ~/.maestro/tests/ | |
| /tmp/e2e-debug/ | |
| e2e-android: | |
| needs: changes | |
| if: needs.changes.outputs.run_e2e == 'true' | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 90 | |
| env: | |
| E2E_APP_ID: com.reactnativereanimatedcarousel.example | |
| MAESTRO_DRIVER_STARTUP_TIMEOUT: "180000" | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - uses: actions/setup-node@v4 | |
| with: | |
| node-version: 22 | |
| cache: yarn | |
| - name: Install dependencies | |
| run: | | |
| set -euxo pipefail | |
| retry() { | |
| local max_retries="$1" | |
| shift | |
| local attempt=1 | |
| until "$@"; do | |
| if [ "$attempt" -ge "$max_retries" ]; then | |
| echo "Command failed after ${attempt} attempts: $*" | |
| return 1 | |
| fi | |
| attempt=$((attempt + 1)) | |
| echo "Retrying (${attempt}/${max_retries}): $*" | |
| sleep 10 | |
| done | |
| } | |
| retry 3 yarn install --frozen-lockfile | |
| cd example/app | |
| retry 3 yarn install --frozen-lockfile | |
| - name: Setup Java | |
| uses: actions/setup-java@v4 | |
| with: | |
| distribution: temurin | |
| java-version: 17 | |
| cache: gradle | |
| - name: Install Maestro | |
| run: | | |
| curl -Ls "https://get.maestro.mobile.dev" | bash | |
| echo "$HOME/.maestro/bin" >> "$GITHUB_PATH" | |
| - name: Prebuild Android | |
| working-directory: example/app | |
| run: npx expo prebuild --platform android --non-interactive | |
| - name: Run Android E2E on emulator | |
| timeout-minutes: 60 | |
| uses: reactivecircus/android-emulator-runner@v2 | |
| with: | |
| api-level: 30 | |
| arch: x86_64 | |
| profile: pixel_7 | |
| disable-animations: true | |
| disable-linux-hw-accel: true | |
| emulator-options: -no-window -gpu swiftshader_indirect -no-snapshot -no-snapshot-save -wipe-data -noaudio -no-boot-anim | |
| script: ./scripts/e2e-android-ci.sh | |
| - name: Upload Android test artifacts | |
| if: always() | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: e2e-results-android | |
| if-no-files-found: warn | |
| retention-days: 7 | |
| path: | | |
| ~/.maestro/tests/ | |
| /tmp/e2e-debug/ |