ci(react-native): add lint, build and UI testing with BrowserStack #141
Workflow file for this run
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: React Native CI | |
| on: | |
| pull_request: | |
| branches: | |
| - main | |
| - 'sdk-*' | |
| paths: | |
| - 'react-native/**' | |
| push: | |
| branches: | |
| - main | |
| - 'sdk-*' | |
| paths: | |
| - 'react-native/**' | |
| concurrency: | |
| group: ${{ github.workflow }}-${{ github.ref }} | |
| cancel-in-progress: true | |
| jobs: | |
| lint: | |
| name: Lint | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 10 | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - name: Setup Node.js | |
| uses: actions/setup-node@v4 | |
| with: | |
| node-version: '20' | |
| cache: 'yarn' | |
| cache-dependency-path: react-native/yarn.lock | |
| - name: Install dependencies | |
| working-directory: react-native | |
| run: yarn install --frozen-lockfile | |
| - name: Run linting | |
| working-directory: react-native | |
| run: yarn lint | |
| build-android: | |
| name: Build Android | |
| runs-on: ubuntu-latest | |
| needs: lint | |
| timeout-minutes: 30 | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - name: Setup Node.js | |
| uses: actions/setup-node@v4 | |
| with: | |
| node-version: '20' | |
| cache: 'yarn' | |
| cache-dependency-path: react-native/yarn.lock | |
| - name: Install dependencies | |
| working-directory: react-native | |
| run: yarn install --frozen-lockfile | |
| - name: Setup Java | |
| uses: actions/setup-java@v4 | |
| with: | |
| distribution: 'temurin' | |
| java-version: '17' | |
| - name: Setup Android SDK | |
| uses: android-actions/setup-android@v3 | |
| - name: Cache Gradle dependencies | |
| uses: actions/cache@v4 | |
| with: | |
| path: | | |
| ~/.gradle/caches | |
| ~/.gradle/wrapper | |
| react-native/android/.gradle | |
| key: gradle-${{ runner.os }}-${{ hashFiles('react-native/android/gradle/wrapper/gradle-wrapper.properties', 'react-native/android/**/*.gradle*') }} | |
| restore-keys: | | |
| gradle-${{ runner.os }}- | |
| - name: Build Android APK | |
| working-directory: react-native/android | |
| run: ./gradlew assembleDebug --no-daemon --console=plain | |
| - name: Upload Android APK artifact | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: android-apk-${{ github.run_number }} | |
| path: react-native/android/app/build/outputs/apk/debug/app-debug.apk | |
| retention-days: 1 | |
| build-ios: | |
| name: Build iOS | |
| runs-on: macos-latest | |
| needs: lint | |
| timeout-minutes: 30 | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - name: Setup Node.js | |
| uses: actions/setup-node@v4 | |
| with: | |
| node-version: '20' | |
| cache: 'yarn' | |
| cache-dependency-path: react-native/yarn.lock | |
| - name: Install dependencies | |
| working-directory: react-native | |
| run: yarn install --frozen-lockfile | |
| - name: Cache CocoaPods dependencies | |
| uses: actions/cache@v4 | |
| with: | |
| path: | | |
| react-native/ios/Pods | |
| ~/Library/Caches/CocoaPods | |
| ~/.cocoapods | |
| key: cocoapods-${{ runner.os }}-${{ hashFiles('react-native/ios/Podfile.lock') }} | |
| restore-keys: | | |
| cocoapods-${{ runner.os }}- | |
| - name: Setup Xcode | |
| uses: maxim-lobanov/setup-xcode@v1 | |
| with: | |
| xcode-version: '16.4' | |
| - name: Install Ruby gems | |
| working-directory: react-native | |
| run: bundle install | |
| - name: Install CocoaPods | |
| working-directory: react-native/ios | |
| run: bundle exec pod install | |
| - name: Build iOS IPA | |
| working-directory: react-native | |
| run: yarn build:ios | |
| - name: Build iOS Archive and IPA (for BrowserStack) | |
| working-directory: react-native | |
| run: | | |
| echo "🍎 Building iOS device .ipa for BrowserStack..." | |
| # Build and archive iOS app for real device | |
| xcodebuild -workspace ios/DittoReactNativeSampleApp.xcworkspace \ | |
| -scheme DittoReactNativeSampleApp \ | |
| -configuration Debug \ | |
| -destination 'generic/platform=iOS' \ | |
| -archivePath ios/build/DittoReactNativeSampleApp.xcarchive \ | |
| archive \ | |
| CODE_SIGN_IDENTITY="" \ | |
| CODE_SIGNING_REQUIRED=NO \ | |
| CODE_SIGNING_ALLOWED=NO | |
| echo "📦 Creating unsigned .ipa for BrowserStack..." | |
| # Find the .app bundle from the archive | |
| APP_BUNDLE_PATH=$(find ios/build/DittoReactNativeSampleApp.xcarchive/Products/Applications -maxdepth 1 -name "*.app" -type d | head -1) | |
| if [ -d "$APP_BUNDLE_PATH" ]; then | |
| echo "✅ iOS app bundle found: $APP_BUNDLE_PATH" | |
| # Create unsigned IPA: Payload/<App>.app zipped as .ipa | |
| mkdir -p ios/build/Payload | |
| cp -R "$APP_BUNDLE_PATH" ios/build/Payload/ | |
| (cd ios/build && zip -qry DittoReactNativeSampleApp-unsigned.ipa Payload && rm -rf Payload) | |
| if [ -f "ios/build/DittoReactNativeSampleApp-unsigned.ipa" ]; then | |
| echo "✅ Unsigned .ipa created successfully" | |
| ls -la ios/build/DittoReactNativeSampleApp-unsigned.ipa | |
| else | |
| echo "❌ Failed to create .ipa file" | |
| exit 1 | |
| fi | |
| else | |
| echo "❌ iOS app bundle not found in archive" | |
| exit 1 | |
| fi | |
| - name: Upload iOS IPA artifact | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: ios-ipa-${{ github.run_number }} | |
| path: react-native/ios/build/DittoReactNativeSampleApp-unsigned.ipa | |
| retention-days: 1 | |
| seed-ditto-cloud: | |
| name: Seed Ditto Cloud | |
| runs-on: ubuntu-latest | |
| needs: lint | |
| timeout-minutes: 10 | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - name: Insert test document into Ditto Cloud | |
| run: | | |
| # Create a unique GitHub test document with inverted timestamp to appear at top | |
| TIMESTAMP=$(date +%s) | |
| INVERTED_TIMESTAMP=$((9999999999 - TIMESTAMP)) | |
| DOC_ID="${INVERTED_TIMESTAMP}_rn_test_${GITHUB_RUN_ID}_${GITHUB_RUN_NUMBER}" | |
| DOC_TITLE="Basic Test Task" | |
| echo "📝 Inserting GitHub test document (inverted timestamp for top position)" | |
| echo "📝 ID: '${DOC_ID}'" | |
| echo "📝 Title: '${DOC_TITLE}'" | |
| echo "📝 Timestamp: ${TIMESTAMP} → Inverted: ${INVERTED_TIMESTAMP}" | |
| # Insert document using Ditto API v4 (same as Swift workflow) | |
| RESPONSE=$(curl -s -w "\n%{http_code}" -X POST \ | |
| -H 'Content-type: application/json' \ | |
| -H "Authorization: Bearer ${{ secrets.DITTO_API_KEY }}" \ | |
| -d "{ | |
| \"statement\": \"INSERT INTO tasks DOCUMENTS (:newTask) ON ID CONFLICT DO UPDATE\", | |
| \"args\": { | |
| \"newTask\": { | |
| \"_id\": \"${DOC_ID}\", | |
| \"text\": \"${DOC_TITLE}\", | |
| \"isCompleted\": false | |
| } | |
| } | |
| }" \ | |
| "https://${{ secrets.DITTO_API_URL }}/api/v4/store/execute") | |
| # Extract HTTP status code and response body | |
| HTTP_CODE=$(echo "$RESPONSE" | tail -n1) | |
| BODY=$(echo "$RESPONSE" | sed '$d') | |
| # Check if insertion was successful | |
| if [ "$HTTP_CODE" -eq 200 ] || [ "$HTTP_CODE" -eq 201 ]; then | |
| echo "✓ Successfully inserted test document with ID: ${DOC_ID}" | |
| echo "✓ Document title: ${DOC_TITLE}" | |
| echo "GITHUB_TEST_DOC_ID=${DOC_ID}" >> $GITHUB_ENV | |
| echo "GITHUB_TEST_DOC_TITLE=${DOC_TITLE}" >> $GITHUB_ENV | |
| else | |
| echo "❌ Failed to insert document. HTTP Status: $HTTP_CODE" | |
| echo "Response: $BODY" | |
| exit 1 | |
| fi | |
| test-android-maestro: | |
| name: Test Android - BrowserStack Maestro | |
| runs-on: ubuntu-latest | |
| needs: [build-android, seed-ditto-cloud] | |
| timeout-minutes: 30 | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - name: Download Android APK | |
| uses: actions/download-artifact@v4 | |
| with: | |
| name: android-apk-${{ github.run_number }} | |
| path: ./artifacts/ | |
| - name: Upload APK to BrowserStack | |
| id: upload-apk | |
| run: | | |
| response=$(curl -u "${{ secrets.BROWSERSTACK_USERNAME }}:${{ secrets.BROWSERSTACK_ACCESS_KEY }}" \ | |
| -X POST "https://api-cloud.browserstack.com/app-automate/maestro/v2/app" \ | |
| -F "file=@./artifacts/app-debug.apk" \ | |
| -F "custom_id=ReactNative-Android-${{ github.run_id }}") | |
| echo "Upload response: $response" | |
| app_url=$(echo $response | jq -r '.app_url') | |
| # Validate API response | |
| if [ -z "$app_url" ] || [ "$app_url" = "null" ]; then | |
| echo "❌ Failed to get app_url from upload response" | |
| echo "Response: $response" | |
| exit 1 | |
| fi | |
| echo "✅ APK uploaded successfully: $app_url" | |
| echo "app_url=$app_url" >> $GITHUB_OUTPUT | |
| - name: Create Maestro test suite ZIP | |
| run: | | |
| cd react-native | |
| zip -r maestro-tests.zip .maestro -x "*.DS_Store" | |
| ls -la maestro-tests.zip | |
| - name: Upload Maestro test suite to BrowserStack | |
| id: upload-tests | |
| run: | | |
| # Verify ZIP file exists before uploading | |
| pwd | |
| ls -la react-native/maestro-tests.zip | |
| response=$(curl --fail-with-body -u "${{ secrets.BROWSERSTACK_USERNAME }}:${{ secrets.BROWSERSTACK_ACCESS_KEY }}" \ | |
| -X POST "https://api-cloud.browserstack.com/app-automate/maestro/v2/test-suite" \ | |
| -F "file=@react-native/maestro-tests.zip" \ | |
| -F "custom_id=ReactNative-Tests-${{ github.run_id }}") | |
| echo "Upload response: $response" | |
| test_suite_url=$(echo $response | jq -r '.test_suite_url') | |
| # Validate API response | |
| if [ -z "$test_suite_url" ] || [ "$test_suite_url" = "null" ]; then | |
| echo "❌ Failed to get test_suite_url from upload response" | |
| echo "Response: $response" | |
| exit 1 | |
| fi | |
| echo "✅ Test suite uploaded successfully: $test_suite_url" | |
| echo "test_suite_url=$test_suite_url" >> $GITHUB_OUTPUT | |
| - name: Execute Maestro tests on BrowserStack (Parallel) | |
| id: execute-tests | |
| run: | | |
| response=$(curl -u "${{ secrets.BROWSERSTACK_USERNAME }}:${{ secrets.BROWSERSTACK_ACCESS_KEY }}" \ | |
| -X POST "https://api-cloud.browserstack.com/app-automate/maestro/v2/android/build" \ | |
| -H "Content-Type: application/json" \ | |
| -d '{ | |
| "app": "${{ steps.upload-apk.outputs.app_url }}", | |
| "testSuite": "${{ steps.upload-tests.outputs.test_suite_url }}", | |
| "project": "Ditto React Native", | |
| "buildName": "Build #${{ github.run_number }}", | |
| "buildTag": "${{ github.ref_name }}", | |
| "devices": [ | |
| "Samsung Galaxy S22-12.0", | |
| "Google Pixel 7-13.0" | |
| ], | |
| "execute": ["flows/01-app-launch-and-seeded-tasks.yaml"], | |
| "deviceLogs": true, | |
| "networkLogs": true, | |
| "video": true | |
| }') | |
| echo "Execution response: $response" | |
| build_id=$(echo $response | jq -r '.build_id') | |
| echo "build_id=$build_id" >> $GITHUB_OUTPUT | |
| echo "BrowserStack Build ID: $build_id" | |
| - name: Wait for test completion and get results | |
| run: | | |
| build_id="${{ steps.execute-tests.outputs.build_id }}" | |
| # Wait for tests to complete (max 20 minutes) | |
| for i in {1..120}; do | |
| response=$(curl -s -u "${{ secrets.BROWSERSTACK_USERNAME }}:${{ secrets.BROWSERSTACK_ACCESS_KEY }}" \ | |
| "https://api-cloud.browserstack.com/app-automate/maestro/v2/builds/$build_id") | |
| status=$(echo $response | jq -r '.status') | |
| echo "Test status: $status (attempt $i/120)" | |
| if [ "$status" = "passed" ] || [ "$status" = "failed" ]; then | |
| echo "Tests completed with status: $status" | |
| echo $response | jq '.' | |
| if [ "$status" = "failed" ]; then | |
| echo "❌ Android Maestro tests failed!" | |
| exit 1 | |
| else | |
| echo "✅ Android Maestro tests passed!" | |
| fi | |
| break | |
| elif [ "$status" = "error" ]; then | |
| echo "❌ BrowserStack reported build error. Fetching session details..." | |
| echo $response | jq '.' | |
| echo $response | jq -r '.devices[]?.sessions[]?.id' 2>/dev/null | while read sid; do | |
| if [ -n "$sid" ]; then | |
| echo "Session $sid details:" | |
| curl -s -u "${{ secrets.BROWSERSTACK_USERNAME }}:${{ secrets.BROWSERSTACK_ACCESS_KEY }}" \ | |
| "https://api-cloud.browserstack.com/app-automate/maestro/v2/builds/$build_id/sessions/$sid" | jq '.' | |
| fi | |
| done | |
| exit 1 | |
| fi | |
| sleep 10 | |
| done | |
| if [ $i -eq 120 ]; then | |
| echo "❌ Tests timed out after 20 minutes" | |
| exit 1 | |
| fi | |
| test-ios-maestro: | |
| name: Test iOS - BrowserStack Maestro | |
| runs-on: ubuntu-latest | |
| needs: [build-ios, seed-ditto-cloud] | |
| timeout-minutes: 30 | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - name: Download iOS IPA | |
| uses: actions/download-artifact@v4 | |
| with: | |
| name: ios-ipa-${{ github.run_number }} | |
| path: ./artifacts/ | |
| - name: Upload IPA to BrowserStack | |
| id: upload-ipa | |
| run: | | |
| response=$(curl -u "${{ secrets.BROWSERSTACK_USERNAME }}:${{ secrets.BROWSERSTACK_ACCESS_KEY }}" \ | |
| -X POST "https://api-cloud.browserstack.com/app-automate/maestro/v2/app" \ | |
| -F "file=@./artifacts/DittoReactNativeSampleApp-unsigned.ipa" \ | |
| -F "custom_id=ReactNative-iOS-${{ github.run_id }}") | |
| echo "Upload response: $response" | |
| app_url=$(echo $response | jq -r '.app_url') | |
| # Validate API response | |
| if [ -z "$app_url" ] || [ "$app_url" = "null" ]; then | |
| echo "❌ Failed to get app_url from upload response" | |
| echo "Response: $response" | |
| exit 1 | |
| fi | |
| echo "✅ IPA uploaded successfully: $app_url" | |
| echo "app_url=$app_url" >> $GITHUB_OUTPUT | |
| - name: Create Maestro test suite ZIP | |
| run: | | |
| cd react-native | |
| zip -r maestro-tests.zip .maestro -x "*.DS_Store" | |
| ls -la maestro-tests.zip | |
| - name: Upload Maestro test suite to BrowserStack | |
| id: upload-tests | |
| run: | | |
| response=$(curl -u "${{ secrets.BROWSERSTACK_USERNAME }}:${{ secrets.BROWSERSTACK_ACCESS_KEY }}" \ | |
| -X POST "https://api-cloud.browserstack.com/app-automate/maestro/v2/test-suite" \ | |
| -F "file=@react-native/maestro-tests.zip" \ | |
| -F "custom_id=ReactNative-iOS-Tests-${{ github.run_id }}") | |
| echo "Upload response: $response" | |
| test_suite_url=$(echo $response | jq -r '.test_suite_url') | |
| echo "test_suite_url=$test_suite_url" >> $GITHUB_OUTPUT | |
| - name: Execute Maestro tests on BrowserStack (Parallel) | |
| id: execute-tests | |
| run: | | |
| response=$(curl -u "${{ secrets.BROWSERSTACK_USERNAME }}:${{ secrets.BROWSERSTACK_ACCESS_KEY }}" \ | |
| -X POST "https://api-cloud.browserstack.com/app-automate/maestro/v2/ios/build" \ | |
| -H "Content-Type: application/json" \ | |
| -d '{ | |
| "app": "${{ steps.upload-ipa.outputs.app_url }}", | |
| "testSuite": "${{ steps.upload-tests.outputs.test_suite_url }}", | |
| "project": "Ditto React Native", | |
| "buildName": "Build #${{ github.run_number }}", | |
| "buildTag": "${{ github.ref_name }}", | |
| "devices": [ | |
| "iPhone 15-17.0", | |
| "iPhone 14-16.0" | |
| ], | |
| "execute": ["flows/01-app-launch-and-seeded-tasks-ios.yaml"], | |
| "deviceLogs": true, | |
| "networkLogs": true, | |
| "video": true | |
| }') | |
| echo "Execution response: $response" | |
| build_id=$(echo $response | jq -r '.build_id') | |
| echo "build_id=$build_id" >> $GITHUB_OUTPUT | |
| echo "BrowserStack Build ID: $build_id" | |
| - name: Wait for test completion and get results | |
| run: | | |
| build_id="${{ steps.execute-tests.outputs.build_id }}" | |
| # Wait for tests to complete (max 20 minutes) | |
| for i in {1..120}; do | |
| response=$(curl -s -u "${{ secrets.BROWSERSTACK_USERNAME }}:${{ secrets.BROWSERSTACK_ACCESS_KEY }}" \ | |
| "https://api-cloud.browserstack.com/app-automate/maestro/v2/builds/$build_id") | |
| status=$(echo $response | jq -r '.status') | |
| echo "Test status: $status (attempt $i/120)" | |
| if [ "$status" = "passed" ] || [ "$status" = "failed" ]; then | |
| echo "Tests completed with status: $status" | |
| echo $response | jq '.' | |
| if [ "$status" = "failed" ]; then | |
| echo "❌ iOS Maestro tests failed!" | |
| exit 1 | |
| else | |
| echo "✅ iOS Maestro tests passed!" | |
| fi | |
| break | |
| elif [ "$status" = "error" ]; then | |
| echo "❌ BrowserStack reported build error. Fetching session details..." | |
| echo $response | jq '.' | |
| echo $response | jq -r '.devices[]?.sessions[]?.id' 2>/dev/null | while read sid; do | |
| if [ -n "$sid" ]; then | |
| echo "Session $sid details:" | |
| curl -s -u "${{ secrets.BROWSERSTACK_USERNAME }}:${{ secrets.BROWSERSTACK_ACCESS_KEY }}" \ | |
| "https://api-cloud.browserstack.com/app-automate/maestro/v2/builds/$build_id/sessions/$sid" | jq '.' | |
| fi | |
| done | |
| exit 1 | |
| fi | |
| sleep 10 | |
| done | |
| if [ $i -eq 120 ]; then | |
| echo "❌ Tests timed out after 20 minutes" | |
| exit 1 | |
| fi |