Skip to content

ci(react-native): add lint, build and UI testing with BrowserStack #135

ci(react-native): add lint, build and UI testing with BrowserStack

ci(react-native): add lint, build and UI testing with BrowserStack #135

Workflow file for this run

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
run: yarn build:android
- name: Upload Android APK artifact
uses: actions/upload-artifact@v4
with:
name: android-apk
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
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
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/.maestro
zip -r ../../maestro-tests.zip . -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=@./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": ["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
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
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/.maestro
zip -r ../../maestro-tests.zip . -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=@./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": ["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
fi
sleep 10
done
if [ $i -eq 120 ]; then
echo "❌ Tests timed out after 20 minutes"
exit 1
fi