Skip to content

ci(e2e): run iOS and Android Maestro tests in parallel #51

ci(e2e): run iOS and Android Maestro tests in parallel

ci(e2e): run iOS and Android Maestro tests in parallel #51

Workflow file for this run

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/