diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml new file mode 100644 index 00000000..8d2bbb99 --- /dev/null +++ b/.github/workflows/e2e.yml @@ -0,0 +1,191 @@ +name: E2E Tests +on: + push: + branches: + - main + pull_request: + branches: + - main + merge_group: + types: + - checks_requested + +jobs: + changes: + runs-on: ubuntu-latest + outputs: + android: ${{ steps.filter.outputs.android }} + ios: ${{ steps.filter.outputs.ios }} + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Check file changes + uses: dorny/paths-filter@v3 + id: filter + with: + filters: | + android: + - 'android/**' + - 'apps/example/android/**' + - 'apps/example/src/**' + - 'src/**' + - 'cpp/**' + - 'package.json' + - 'apps/example/package.json' + - 'react-native.config.js' + - 'babel.config.js' + - '.maestro/**' + ios: + - 'ios/**' + - 'apps/example/ios/**' + - 'apps/example/src/**' + - 'src/**' + - 'cpp/**' + - '*.podspec' + - 'package.json' + - 'apps/example/package.json' + - 'react-native.config.js' + - 'babel.config.js' + - '.maestro/**' + + e2e-ios: + needs: [changes] + if: needs.changes.outputs.ios == 'true' + runs-on: macos-latest + timeout-minutes: 60 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup + uses: ./.github/actions/setup + + - name: Set up Xcode + uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: latest-stable + + - name: Restore cocoapods + id: cocoapods-cache + uses: actions/cache/restore@v4 + with: + path: | + **/ios/Pods + key: ${{ runner.os }}-cocoapods-${{ hashFiles('apps/example/ios/Podfile.lock') }} + restore-keys: | + ${{ runner.os }}-cocoapods- + + - name: Install cocoapods + if: steps.cocoapods-cache.outputs.cache-hit != 'true' + run: | + cd apps/example/ios + pod install + env: + NO_FLIPPER: 1 + + - name: Cache cocoapods + if: steps.cocoapods-cache.outputs.cache-hit != 'true' + uses: actions/cache/save@v4 + with: + path: | + **/ios/Pods + key: ${{ steps.cocoapods-cache.outputs.cache-key }} + + - name: Restore Xcode DerivedData + uses: actions/cache/restore@v4 + id: derived-data-cache + with: + path: ~/Library/Developer/Xcode/DerivedData + key: ${{ runner.os }}-derived-data-${{ hashFiles('ios/**', 'apps/example/ios/**', 'cpp/**', 'src/**') }} + restore-keys: | + ${{ runner.os }}-derived-data- + + - name: Install Maestro CLI + run: | + curl -Ls "https://get.maestro.mobile.dev" | bash + echo "$HOME/.maestro/bin" >> $GITHUB_PATH + + - name: Start Metro + run: yarn example start & + + - name: Run E2E tests + run: yarn test:e2e:ios + + - name: Cache Xcode DerivedData + if: steps.derived-data-cache.outputs.cache-hit != 'true' + uses: actions/cache/save@v4 + with: + path: ~/Library/Developer/Xcode/DerivedData + key: ${{ steps.derived-data-cache.outputs.cache-primary-key }} + + - name: Upload test artifacts + if: failure() + uses: actions/upload-artifact@v4 + with: + name: e2e-ios-artifacts + path: | + .maestro/screenshots/ios/*_diff.png + + e2e-android: + needs: [changes] + if: needs.changes.outputs.android == 'true' + runs-on: ubuntu-latest + timeout-minutes: 60 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup + uses: ./.github/actions/setup + + - 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 + + - name: Install JDK + uses: actions/setup-java@v4 + with: + distribution: 'zulu' + java-version: '17' + + - name: Finalize Android SDK + run: | + /bin/bash -c "yes | $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager --licenses > /dev/null" + $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager "emulator" "platform-tools" + echo "$ANDROID_HOME/cmdline-tools/latest/bin" >> $GITHUB_PATH + echo "$ANDROID_HOME/platform-tools" >> $GITHUB_PATH + echo "$ANDROID_HOME/emulator" >> $GITHUB_PATH + + - name: Install Maestro CLI + run: | + curl -Ls "https://get.maestro.mobile.dev" | bash + echo "$HOME/.maestro/bin" >> $GITHUB_PATH + + - name: Cache Gradle + uses: actions/cache@v4 + with: + path: | + ~/.gradle/wrapper + ~/.gradle/caches + key: ${{ runner.os }}-gradle-${{ hashFiles('apps/example/android/gradle/wrapper/gradle-wrapper.properties') }} + restore-keys: | + ${{ runner.os }}-gradle- + + - name: Start Metro + run: yarn example start & + + - name: Run E2E tests + run: yarn test:e2e:android + env: + JAVA_OPTS: '-XX:MaxHeapSize=6g' + + - name: Upload test artifacts + if: failure() + uses: actions/upload-artifact@v4 + with: + name: e2e-android-artifacts + path: | + .maestro/screenshots/android/*_diff.png diff --git a/.maestro/screenshots/android/empty_element_parsing.png b/.maestro/screenshots/android/empty_element_parsing.png index 7e28ad4e..04053f6a 100644 Binary files a/.maestro/screenshots/android/empty_element_parsing.png and b/.maestro/screenshots/android/empty_element_parsing.png differ diff --git a/.maestro/screenshots/android/paragraph_styles_no_crash.png b/.maestro/screenshots/android/paragraph_styles_no_crash.png index 3278d742..5bf91891 100644 Binary files a/.maestro/screenshots/android/paragraph_styles_no_crash.png and b/.maestro/screenshots/android/paragraph_styles_no_crash.png differ diff --git a/.maestro/screenshots/android/scrolling_paragraph_styles_top.png b/.maestro/screenshots/android/scrolling_paragraph_styles_top.png index 3b946339..f46c6124 100644 Binary files a/.maestro/screenshots/android/scrolling_paragraph_styles_top.png and b/.maestro/screenshots/android/scrolling_paragraph_styles_top.png differ diff --git a/.maestro/scripts/run-tests.sh b/.maestro/scripts/run-tests.sh index 0e6c64dd..4dbeb001 100755 --- a/.maestro/scripts/run-tests.sh +++ b/.maestro/scripts/run-tests.sh @@ -26,7 +26,7 @@ if ! command -v maestro >/dev/null 2>&1; then exit 1 fi -MAESTRO_VERSION=$(maestro --version) +MAESTRO_VERSION=$(maestro --version | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1) # Compare versions by sorting them; if the minimum sorts after the actual, it's too old. if [ "$(printf '%s\n' "$MIN_MAESTRO_VERSION" "$MAESTRO_VERSION" | sort -V | head -n1)" != "$MIN_MAESTRO_VERSION" ]; then echo "Error: maestro $MAESTRO_VERSION is too old, minimum required is $MIN_MAESTRO_VERSION" >&2 @@ -60,7 +60,7 @@ case "$PLATFORM" in *) echo "Error: --platform must be ios or android" >&2; exit 1 ;; esac -DEVICE_ID=$("$SETUP" | tee /dev/tty | grep "^DEVICE_ID=" | cut -d= -f2) +DEVICE_ID=$("$SETUP" | tee /dev/stderr | grep "^DEVICE_ID=" | cut -d= -f2) app_installed() { if [ "$PLATFORM" = ios ]; then diff --git a/.maestro/scripts/setup-android-emulator.sh b/.maestro/scripts/setup-android-emulator.sh index 3c74de08..3b8313a2 100755 --- a/.maestro/scripts/setup-android-emulator.sh +++ b/.maestro/scripts/setup-android-emulator.sh @@ -2,7 +2,7 @@ set -euo pipefail API_LEVEL="36" -DEVICE_ID="pixel_9" +DEVICE_ID="pixel_7" ARCH=$(uname -m) if [ "$ARCH" = "arm64" ] || [ "$ARCH" = "aarch64" ]; then ABI="arm64-v8a" @@ -11,7 +11,7 @@ else fi TAG="google_apis_playstore" SYSTEM_IMAGE="system-images;android-${API_LEVEL};${TAG};${ABI}" -AVD_NAME="Pixel9-API${API_LEVEL}-Enriched" +AVD_NAME="Pixel7-API${API_LEVEL}-Enriched" PORT=5570 SERIAL="emulator-${PORT}" @@ -20,6 +20,11 @@ if [ -z "$ANDROID_HOME" ]; then exit 1 fi +# Ensure avdmanager and emulator use the same AVD directory regardless of +# what ANDROID_SDK_HOME is set to on the host (e.g. GitHub Actions runners). +export ANDROID_AVD_HOME="$HOME/.android/avd" +mkdir -p "$ANDROID_AVD_HOME" + for tool in sdkmanager avdmanager emulator adb; do if ! command -v "$tool" &>/dev/null; then echo "Error: '$tool' not found. Ensure Android SDK tools are installed and in PATH." @@ -41,19 +46,20 @@ fi if ! avdmanager list avd -c | grep -qx "${AVD_NAME}"; then echo "Creating AVD '$AVD_NAME'..." - echo "no" | avdmanager create avd \ - --name "$AVD_NAME" \ - --device "$DEVICE_ID" \ - --package "$SYSTEM_IMAGE" \ - --skin "$DEVICE_ID" + CREATE_CMD=(avdmanager create avd --name "$AVD_NAME" --device "$DEVICE_ID" --package "$SYSTEM_IMAGE") + # Skin is cosmetic (phone frame). Skip it on CI since the runner has no skin files + # and the emulator runs headless anyway. + [ -z "${CI:-}" ] && CREATE_CMD+=(--skin "$DEVICE_ID") + echo "no" | "${CREATE_CMD[@]}" fi AVD_CONFIG="$HOME/.android/avd/${AVD_NAME}.avd/config.ini" if [ -f "$AVD_CONFIG" ]; then - sed -i '' 's/^hw\.keyboard=.*/hw.keyboard=yes/' "$AVD_CONFIG" + sed -i.bak 's/^hw\.keyboard=.*/hw.keyboard=yes/' "$AVD_CONFIG" grep -q "^hw.keyboard=" "$AVD_CONFIG" || echo "hw.keyboard=yes" >> "$AVD_CONFIG" - sed -i '' 's/^hw\.mainKeys=.*/hw.mainKeys=yes/' "$AVD_CONFIG" + sed -i.bak 's/^hw\.mainKeys=.*/hw.mainKeys=yes/' "$AVD_CONFIG" grep -q "^hw.mainKeys=" "$AVD_CONFIG" || echo "hw.mainKeys=yes" >> "$AVD_CONFIG" + rm -f "$AVD_CONFIG.bak" fi if pgrep -f "emulator.*${AVD_NAME}" > /dev/null 2>&1; then @@ -63,7 +69,12 @@ if pgrep -f "emulator.*${AVD_NAME}" > /dev/null 2>&1; then fi echo "Starting emulator '$AVD_NAME'..." -emulator "@${AVD_NAME}" -port "$PORT" > /dev/null 2>&1 & +EMULATOR_ARGS=("@${AVD_NAME}" -port "$PORT") +if [ -n "${CI:-}" ]; then + EMULATOR_ARGS+=(-no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim) +fi + +emulator "${EMULATOR_ARGS[@]}" > /dev/null 2>&1 & echo "Waiting for emulator ($SERIAL) to connect to ADB..." if ! timeout 120 adb -s "$SERIAL" wait-for-device; then diff --git a/.maestro/scripts/setup-ios-simulator.sh b/.maestro/scripts/setup-ios-simulator.sh index 66f4cb74..a8ef0c69 100755 --- a/.maestro/scripts/setup-ios-simulator.sh +++ b/.maestro/scripts/setup-ios-simulator.sh @@ -31,7 +31,9 @@ if [ "$STATE" != "(Booted)" ]; then xcrun simctl boot "$UDID" fi -open -a Simulator +if [ -z "${CI:-}" ]; then + open -a Simulator +fi echo "Simulator ready: $DEVICE_NAME ($UDID)" echo "DEVICE_ID=$UDID" diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 59808d07..2aac522d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -92,7 +92,7 @@ The target devices are: | Platform | Device | OS | | -------- | --------- | ----------------------------- | | iOS | iPhone 17 | iOS 26.2 | -| Android | Pixel 9 | API 36 "Baklava" (Android 16) | +| Android | Pixel 7 | API 36 "Baklava" (Android 16) | #### Running E2E tests