diff --git a/.github/actions/run-browserstack-maestro/action.yml b/.github/actions/run-browserstack-maestro/action.yml new file mode 100644 index 0000000..8725865 --- /dev/null +++ b/.github/actions/run-browserstack-maestro/action.yml @@ -0,0 +1,116 @@ +name: Run BrowserStack Maestro Tests + +description: Triggers a BrowserStack Maestro build and polls until completion + +inputs: + platform: + description: Platform to run tests on (android or ios) + required: true + app_url: + description: BrowserStack app URL (bs://...) + required: true + test_suite_url: + description: BrowserStack test suite URL (bs://...) + required: true + devices: + description: JSON array of device strings + required: true + browserstack_project: + description: BrowserStack project + required: true + browserstack_username: + description: BrowserStack username + required: true + browserstack_access_key: + description: BrowserStack access key + required: true + timeout: + description: Test run timeout in seconds + required: false + default: "600" + +runs: + using: composite + steps: + - name: Validate inputs + env: + PLATFORM: ${{ inputs.platform }} + TIMEOUT: ${{ fromJson(inputs.timeout) }} + shell: bash + run: | + if [[ "$PLATFORM" != "android" && "$PLATFORM" != "ios" ]]; then + echo "platform must be android or ios" + exit 1 + fi + + if [[ "$TIMEOUT" -lt 30 ]]; then + echo "timeout must be at least 30 seconds" + exit 1 + fi + + - name: Trigger build + id: trigger + env: + APP_URL: ${{ inputs.app_url }} + TEST_SUITE_URL: ${{ inputs.test_suite_url }} + DEVICES: ${{ inputs.devices }} + BROWSERSTACK_PROJECT: ${{ inputs.browserstack_project }} + BROWSERSTACK_USERNAME: ${{ inputs.browserstack_username }} + BROWSERSTACK_ACCESS_KEY: ${{ inputs.browserstack_access_key }} + PLATFORM: ${{ inputs.platform }} + shell: bash + run: | + PAYLOAD=$(jq -n \ + --arg app "$APP_URL" \ + --arg suite "$TEST_SUITE_URL" \ + --argjson devices "$DEVICES" \ + --arg project "$BROWSERSTACK_PROJECT" \ + '{ + app: $app, + testSuite: $suite, + devices: $devices, + project: $project, + deviceLogs: true, + maestroVersion: "latest" + }' + ) + + BUILD_ID=$(curl --fail --show-error -s -u "$BROWSERSTACK_USERNAME:$BROWSERSTACK_ACCESS_KEY" \ + -X POST "https://api-cloud.browserstack.com/app-automate/maestro/v2/$PLATFORM/build" \ + -H "Content-Type: application/json" \ + -d "$PAYLOAD" \ + | jq -r '.build_id') + + echo "Triggered build: $BUILD_ID" + echo "build_id=$BUILD_ID" >> $GITHUB_OUTPUT + + - name: Poll for result + env: + BUILD_ID: ${{ steps.trigger.outputs.build_id }} + BROWSERSTACK_USERNAME: ${{ inputs.browserstack_username }} + BROWSERSTACK_ACCESS_KEY: ${{ inputs.browserstack_access_key }} + TIMEOUT: ${{ fromJson(inputs.timeout) }} + shell: bash + run: | + MAX_WAIT=$TIMEOUT + POLL_INTERVAL=30 + ELAPSED=0 + + while [ "$ELAPSED" -lt "$MAX_WAIT" ]; do + RESPONSE=$(curl --fail --show-error -s -u "$BROWSERSTACK_USERNAME:$BROWSERSTACK_ACCESS_KEY" \ + "https://api-cloud.browserstack.com/app-automate/maestro/v2/builds/$BUILD_ID") + + STATUS=$(echo "$RESPONSE" | jq -r '.status') + echo "[${ELAPSED}s] Build $BUILD_ID status: $STATUS" + + case "$STATUS" in + passed|completed) echo "Tests passed"; exit 0 ;; + failed) echo "Tests failed"; exit 1 ;; + esac + + sleep "$POLL_INTERVAL" + ELAPSED=$((ELAPSED + POLL_INTERVAL)) + done + + echo "Timed out after ${MAX_WAIT}s waiting for build $BUILD_ID" + exit 1 diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml new file mode 100644 index 0000000..95618e7 --- /dev/null +++ b/.github/workflows/e2e-tests.yml @@ -0,0 +1,270 @@ +name: E2E Tests + +on: + push: + branches: [main] + pull_request: + branches: [main] + types: [opened, synchronize, reopened, ready_for_review] + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + NODEJS_MOBILE_VERSION: v18.20.4 + +jobs: + build-android: + if: ${{ github.event.pull_request.draft == false }} + name: Build (Android) + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + contents: read + outputs: + app_url: ${{ steps.upload.outputs.app_url }} + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + + - uses: actions/setup-node@v6 + with: + node-version-file: package.json + + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: 17 + + - name: Setup Android SDK + uses: android-actions/setup-android@v3 + + - name: Cache nodejs-mobile binaries + id: cache-libnode + uses: actions/cache@v4 + with: + path: android/libnode + # hashFiles(...) folds the BASE_URL (defined inside the + # download script) into the cache key, so any swap of the + # source repo automatically invalidates stale caches. + key: nodejs-mobile-${{ env.NODEJS_MOBILE_VERSION }}-android-${{ hashFiles('scripts/download-nodejs-mobile.sh') }} + + - name: Download nodejs-mobile binaries + if: steps.cache-libnode.outputs.cache-hit != 'true' + run: ./scripts/download-nodejs-mobile.sh + + - name: Set up Gradle + uses: gradle/actions/setup-gradle@v4 + + - name: Install npm dependencies + run: | + npm install --ignore-scripts + npx patch-package + + - name: Build backend bundle + run: npm run backend:build + + - name: Set up E2E app + working-directory: apps/e2e + run: | + npm install --ignore-scripts + npx patch-package + npx expo prebuild --platform android --no-install + + - name: Build APK + working-directory: apps/e2e/android + run: | + ./gradlew assembleRelease --no-daemon + + - name: Upload APK + id: upload + env: + BROWSERSTACK_ACCESS_KEY: ${{ secrets.BROWSERSTACK_ACCESS_KEY_TESTS }} + BROWSERSTACK_USERNAME: ${{ secrets.BROWSERSTACK_USERNAME_TESTS }} + run: | + APK_RELATIVE_PATH="./apps/e2e/android/app/build/outputs/apk/release/app-release.apk" + + APP_URL=$(curl --fail --show-error -s -u "$BROWSERSTACK_USERNAME:$BROWSERSTACK_ACCESS_KEY" \ + -X POST "https://api-cloud.browserstack.com/app-automate/upload" \ + -F "file=@$APK_RELATIVE_PATH" | jq -r '.app_url') + + echo "app_url=$APP_URL" >> $GITHUB_OUTPUT + + build-ios: + if: ${{ github.event.pull_request.draft == false }} + name: Build (iOS) + runs-on: macos-15 + timeout-minutes: 15 + permissions: + contents: read + outputs: + app_url: ${{ steps.upload.outputs.app_url }} + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + + - name: Select Xcode + run: sudo xcode-select -s /Applications/Xcode_26.3.app/Contents/Developer + + - uses: actions/setup-node@v6 + with: + node-version-file: package.json + + - name: Cache nodejs-mobile binaries + id: cache-ios-framework + uses: actions/cache@v4 + with: + path: ios/NodeMobile.xcframework + # hashFiles(...) folds the BASE_URL (defined inside the + # download script) into the cache key, so any swap of the + # source repo automatically invalidates stale caches. + key: nodejs-mobile-${{ env.NODEJS_MOBILE_VERSION }}-ios-${{ hashFiles('scripts/download-nodejs-mobile.sh') }} + + - name: Download nodejs-mobile binaries + if: steps.cache-libnode.outputs.cache-hit != 'true' + run: ./scripts/download-nodejs-mobile.sh + + - name: Install npm dependencies + run: | + npm install --ignore-scripts + npx patch-package + + - name: Build backend bundle + run: npm run backend:build + + - name: Set up E2E app + working-directory: apps/e2e + run: | + npm install --ignore-scripts + npx patch-package + npx expo prebuild --platform ios + + - name: Build .ipa + working-directory: apps/e2e/ios + run: | + xcodebuild archive \ + -workspace corereactnativee2e.xcworkspace \ + -scheme corereactnativee2e \ + -sdk iphoneos \ + -destination 'generic/platform=iOS' \ + -archivePath ./build/corereactnativee2e.xcarchive \ + CODE_SIGNING_ALLOWED='NO' + + mkdir -p Payload + cp -r ./build/corereactnativee2e.xcarchive/Products/Applications/corereactnativee2e.app Payload/ + zip -r corereactnativee2e.ipa Payload/ + + - name: Upload + id: upload + env: + BROWSERSTACK_ACCESS_KEY: ${{ secrets.BROWSERSTACK_ACCESS_KEY_TESTS }} + BROWSERSTACK_USERNAME: ${{ secrets.BROWSERSTACK_USERNAME_TESTS }} + run: | + IPA_RELATIVE_PATH="./apps/e2e/ios/corereactnativee2e.ipa" + + APP_URL=$(curl --fail --show-error -s -u "$BROWSERSTACK_USERNAME:$BROWSERSTACK_ACCESS_KEY" \ + -X POST "https://api-cloud.browserstack.com/app-automate/upload" \ + -F "file=@$IPA_RELATIVE_PATH" | jq -r '.app_url') + + echo "app_url=$APP_URL" >> $GITHUB_OUTPUT + + upload-test-suite: + if: ${{ github.event.pull_request.draft == false }} + name: Upload test suite + # TODO: Enable when we know this job works + # needs: [build-android, build-ios] + runs-on: ubuntu-latest + permissions: + contents: read + outputs: + test_suite_url: ${{ steps.upload.outputs.test_suite_url }} + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + sparse-checkout: maestro + + - name: Upload test suite to Browserstack + id: upload + env: + BROWSERSTACK_ACCESS_KEY: ${{ secrets.BROWSERSTACK_ACCESS_KEY_TESTS }} + BROWSERSTACK_USERNAME: ${{ secrets.BROWSERSTACK_USERNAME_TESTS }} + run: | + ZIP_NAME="flows.zip" + + zip -r "$ZIP_NAME" maestro/e2e.yaml + + TEST_SUITE_URL=$(curl --fail --show-error -s -u "$BROWSERSTACK_USERNAME:$BROWSERSTACK_ACCESS_KEY" \ + -X POST "https://api-cloud.browserstack.com/app-automate/maestro/v2/test-suite" \ + -F "file=@$ZIP_NAME" | jq -r '.test_suite_url') + + echo "test_suite_url=$TEST_SUITE_URL" >> $GITHUB_OUTPUT + + test-android: + name: Run tests (Android) + needs: [build-android, upload-test-suite] + runs-on: ubuntu-latest + # TODO: Adjust based on actual timing + timeout-minutes: 60 + permissions: + contents: read + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + sparse-checkout: .github/actions + + - uses: ./.github/actions/run-browserstack-maestro + with: + platform: android + app_url: ${{ needs.build-android.outputs.app_url }} + test_suite_url: ${{ needs.upload-test-suite.outputs.test_suite_url }} + browserstack_project: ${{ vars.BROWSERSTACK_PROJECT_TESTS }} + browserstack_username: ${{ secrets.BROWSERSTACK_USERNAME_TESTS }} + browserstack_access_key: ${{ secrets.BROWSERSTACK_ACCESS_KEY_TESTS }} + # TODO: Adjust based on actual timing + timeout: 1800 + devices: >- + [ + "Google Pixel 9-16.0", + "Huawei Nova 11 SE-12.0", + "Motorola Moto G71 5G-11.0", + "OnePlus 13R-15.0", + "Oppo Reno 6-11.0", + "Samsung Galaxy Note 9-8.1", + "Vivo Y21-11.0", + "Xiaomi Redmi Note 11-11.0" + ] + + test-ios: + name: Run tests (iOS) + needs: [build-ios, upload-test-suite] + runs-on: ubuntu-latest + # TODO: Adjust based on actual timing + timeout-minutes: 60 + permissions: + contents: read + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + sparse-checkout: .github/actions + + - uses: ./.github/actions/run-browserstack-maestro + with: + platform: ios + app_url: ${{ needs.build-ios.outputs.app_url }} + test_suite_url: ${{ needs.upload-test-suite.outputs.test_suite_url }} + browserstack_project: ${{ vars.BROWSERSTACK_PROJECT_TESTS }} + browserstack_username: ${{ secrets.BROWSERSTACK_USERNAME_TESTS }} + browserstack_access_key: ${{ secrets.BROWSERSTACK_ACCESS_KEY_TESTS }} + # TODO: Adjust based on actual timing + timeout: 1800 + devices: >- + [ + "iPhone 15-17", + "iPhone 17-26" + ] diff --git a/maestro/e2e.yaml b/maestro/e2e.yaml new file mode 100644 index 0000000..9ac2754 --- /dev/null +++ b/maestro/e2e.yaml @@ -0,0 +1,11 @@ +appId: com.comapeo.core.e2e +env: + TIMEOUT: 30000 +--- +- launchApp: + clearState: true +- tapOn: "Run tests" +- extendedWaitUntil: + visible: + id: "all-tests-passed" + timeout: ${TIMEOUT}