Flutter CI #159
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: Flutter CI | |
| on: | |
| pull_request: | |
| paths: | |
| - "flutter_app/**" | |
| - ".github/workflows/flutter-ci.yml" | |
| workflow_dispatch: | |
| concurrency: | |
| group: ${{ github.workflow }}-${{ github.ref }} | |
| cancel-in-progress: true | |
| jobs: | |
| lint: | |
| name: Lint | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 10 | |
| steps: | |
| - name: Checkout code | |
| uses: actions/checkout@v4 | |
| - name: Setup Flutter | |
| uses: subosito/flutter-action@v2 | |
| with: | |
| flutter-version: "3.x" | |
| channel: "stable" | |
| - name: Create .env file for linting | |
| run: | | |
| echo "DITTO_APP_ID=test" > flutter_app/.env | |
| echo "DITTO_PLAYGROUND_TOKEN=test" >> flutter_app/.env | |
| echo "DITTO_AUTH_URL=test" >> flutter_app/.env | |
| echo "DITTO_WEBSOCKET_URL=test" >> flutter_app/.env | |
| - name: Get dependencies | |
| working-directory: flutter_app | |
| run: flutter pub get | |
| - name: Check formatting | |
| working-directory: flutter_app | |
| run: | | |
| dart format --set-exit-if-changed . | |
| if [ $? -ne 0 ]; then | |
| echo "β Code formatting issues found. Run 'dart format .' to fix." | |
| exit 1 | |
| fi | |
| - name: Analyze code | |
| working-directory: flutter_app | |
| run: flutter analyze --no-fatal-infos | |
| build-android: | |
| name: Build Android | |
| runs-on: ubuntu-latest | |
| needs: [lint, unit-tests] | |
| timeout-minutes: 20 | |
| steps: | |
| - name: Checkout code | |
| uses: actions/checkout@v4 | |
| - name: Setup Java | |
| uses: actions/setup-java@v4 | |
| with: | |
| distribution: "temurin" | |
| java-version: "17" | |
| - name: Setup Flutter | |
| uses: subosito/flutter-action@v2 | |
| with: | |
| flutter-version: "3.x" | |
| channel: "stable" | |
| - name: Create .env file | |
| run: | | |
| echo "DITTO_APP_ID=${{ secrets.DITTO_APP_ID }}" > flutter_app/.env | |
| echo "DITTO_PLAYGROUND_TOKEN=${{ secrets.DITTO_PLAYGROUND_TOKEN }}" >> flutter_app/.env | |
| echo "DITTO_AUTH_URL=${{ secrets.DITTO_AUTH_URL }}" >> flutter_app/.env | |
| echo "DITTO_WEBSOCKET_URL=${{ secrets.DITTO_WEBSOCKET_URL }}" >> flutter_app/.env | |
| - name: Cache Flutter dependencies | |
| uses: actions/cache@v4 | |
| with: | |
| path: | | |
| ~/.pub-cache | |
| flutter_app/.dart_tool | |
| flutter_app/build | |
| key: flutter-${{ runner.os }}-${{ hashFiles('flutter_app/pubspec.lock') }} | |
| restore-keys: | | |
| flutter-${{ runner.os }}- | |
| - name: Get dependencies | |
| working-directory: flutter_app | |
| run: flutter pub get | |
| - name: Build Android APK (sanity check) | |
| working-directory: flutter_app | |
| run: flutter build apk --release | |
| - name: Upload APK artifact | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: flutter-android-apk-${{ github.run_number }} | |
| path: flutter_app/build/app/outputs/flutter-apk/app-release.apk | |
| retention-days: 1 | |
| build-ios: | |
| name: Build iOS | |
| runs-on: macos-latest | |
| needs: [lint, unit-tests] | |
| timeout-minutes: 30 | |
| steps: | |
| - name: Checkout code | |
| uses: actions/checkout@v4 | |
| - name: Setup Flutter | |
| uses: subosito/flutter-action@v2 | |
| with: | |
| flutter-version: "3.x" | |
| channel: "stable" | |
| - name: Create .env file | |
| run: | | |
| echo "DITTO_APP_ID=${{ secrets.DITTO_APP_ID }}" > flutter_app/.env | |
| echo "DITTO_PLAYGROUND_TOKEN=${{ secrets.DITTO_PLAYGROUND_TOKEN }}" >> flutter_app/.env | |
| echo "DITTO_AUTH_URL=${{ secrets.DITTO_AUTH_URL }}" >> flutter_app/.env | |
| echo "DITTO_WEBSOCKET_URL=${{ secrets.DITTO_WEBSOCKET_URL }}" >> flutter_app/.env | |
| - name: Cache Flutter dependencies | |
| uses: actions/cache@v4 | |
| with: | |
| path: | | |
| ~/.pub-cache | |
| flutter_app/.dart_tool | |
| flutter_app/build | |
| key: flutter-${{ runner.os }}-${{ hashFiles('flutter_app/pubspec.lock') }} | |
| restore-keys: | | |
| flutter-${{ runner.os }}- | |
| - name: Cache CocoaPods dependencies | |
| uses: actions/cache@v4 | |
| with: | |
| path: flutter_app/ios/Pods | |
| key: pods-${{ runner.os }}-${{ hashFiles('flutter_app/ios/Podfile.lock') }} | |
| restore-keys: | | |
| pods-${{ runner.os }}- | |
| - name: Get dependencies | |
| working-directory: flutter_app | |
| run: flutter pub get | |
| - name: Install CocoaPods | |
| working-directory: flutter_app/ios | |
| run: pod install || pod install --repo-update | |
| - name: Build iOS (no signing) | |
| working-directory: flutter_app | |
| run: flutter build ios --release --no-codesign | |
| - name: Create unsigned IPA | |
| working-directory: flutter_app | |
| run: | | |
| mkdir -p build/ios/iphoneos/Payload | |
| cp -r build/ios/iphoneos/Runner.app build/ios/iphoneos/Payload/ | |
| cd build/ios/iphoneos | |
| zip -r Runner-unsigned.ipa Payload/ | |
| echo "β Created unsigned IPA" | |
| - name: Upload iOS build artifact | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: flutter-ios-ipa-${{ github.run_number }} | |
| path: flutter_app/build/ios/iphoneos/Runner-unsigned.ipa | |
| retention-days: 1 | |
| build-web: | |
| name: Build Web | |
| runs-on: ubuntu-latest | |
| needs: [lint, unit-tests] | |
| timeout-minutes: 15 | |
| steps: | |
| - name: Checkout code | |
| uses: actions/checkout@v4 | |
| - name: Setup Flutter | |
| uses: subosito/flutter-action@v2 | |
| with: | |
| flutter-version: "3.x" | |
| channel: "stable" | |
| - name: Create .env file | |
| run: | | |
| echo "DITTO_APP_ID=${{ secrets.DITTO_APP_ID }}" > flutter_app/.env | |
| echo "DITTO_PLAYGROUND_TOKEN=${{ secrets.DITTO_PLAYGROUND_TOKEN }}" >> flutter_app/.env | |
| echo "DITTO_AUTH_URL=${{ secrets.DITTO_AUTH_URL }}" >> flutter_app/.env | |
| echo "DITTO_WEBSOCKET_URL=${{ secrets.DITTO_WEBSOCKET_URL }}" >> flutter_app/.env | |
| - name: Cache Flutter dependencies | |
| uses: actions/cache@v4 | |
| with: | |
| path: | | |
| ~/.pub-cache | |
| flutter_app/.dart_tool | |
| flutter_app/build | |
| key: flutter-${{ runner.os }}-${{ hashFiles('flutter_app/pubspec.lock') }} | |
| restore-keys: | | |
| flutter-${{ runner.os }}- | |
| - name: Get dependencies | |
| working-directory: flutter_app | |
| run: flutter pub get | |
| - name: Build Web | |
| working-directory: flutter_app | |
| run: flutter build web --release | |
| - name: Upload Web build artifact | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: flutter-web-${{ github.run_number }} | |
| path: flutter_app/build/web/ | |
| retention-days: 1 | |
| build-macos: | |
| name: Build macOS Desktop | |
| runs-on: macos-latest | |
| needs: [lint, unit-tests] | |
| timeout-minutes: 20 | |
| steps: | |
| - name: Checkout code | |
| uses: actions/checkout@v4 | |
| - name: Setup Flutter | |
| uses: subosito/flutter-action@v2 | |
| with: | |
| flutter-version: "3.x" | |
| channel: "stable" | |
| - name: Create .env file | |
| run: | | |
| echo "DITTO_APP_ID=${{ secrets.DITTO_APP_ID }}" > flutter_app/.env | |
| echo "DITTO_PLAYGROUND_TOKEN=${{ secrets.DITTO_PLAYGROUND_TOKEN }}" >> flutter_app/.env | |
| echo "DITTO_AUTH_URL=${{ secrets.DITTO_AUTH_URL }}" >> flutter_app/.env | |
| echo "DITTO_WEBSOCKET_URL=${{ secrets.DITTO_WEBSOCKET_URL }}" >> flutter_app/.env | |
| - name: Cache Flutter dependencies | |
| uses: actions/cache@v4 | |
| with: | |
| path: | | |
| ~/.pub-cache | |
| flutter_app/.dart_tool | |
| flutter_app/build | |
| key: flutter-${{ runner.os }}-${{ hashFiles('flutter_app/pubspec.lock') }} | |
| restore-keys: | | |
| flutter-${{ runner.os }}- | |
| - name: Get dependencies | |
| working-directory: flutter_app | |
| run: flutter pub get | |
| - name: Build macOS Desktop | |
| working-directory: flutter_app | |
| run: flutter build macos --release | |
| - name: Upload macOS build artifact | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: flutter-macos-${{ github.run_number }} | |
| path: flutter_app/build/macos/Build/Products/Release/ | |
| retention-days: 1 | |
| build-windows: | |
| name: Build Windows Desktop | |
| runs-on: windows-latest | |
| needs: [lint, unit-tests] | |
| timeout-minutes: 20 | |
| steps: | |
| - name: Checkout code | |
| uses: actions/checkout@v4 | |
| - name: Setup Flutter | |
| uses: subosito/flutter-action@v2 | |
| with: | |
| flutter-version: "3.x" | |
| channel: "stable" | |
| - name: Create .env file | |
| run: | | |
| echo "DITTO_APP_ID=${{ secrets.DITTO_APP_ID }}" > flutter_app/.env | |
| echo "DITTO_PLAYGROUND_TOKEN=${{ secrets.DITTO_PLAYGROUND_TOKEN }}" >> flutter_app/.env | |
| echo "DITTO_AUTH_URL=${{ secrets.DITTO_AUTH_URL }}" >> flutter_app/.env | |
| echo "DITTO_WEBSOCKET_URL=${{ secrets.DITTO_WEBSOCKET_URL }}" >> flutter_app/.env | |
| - name: Cache Flutter dependencies | |
| uses: actions/cache@v4 | |
| with: | |
| path: | | |
| ~\AppData\Local\Pub\Cache | |
| flutter_app\.dart_tool | |
| flutter_app\build | |
| key: flutter-${{ runner.os }}-${{ hashFiles('flutter_app/pubspec.lock') }} | |
| restore-keys: | | |
| flutter-${{ runner.os }}- | |
| - name: Get dependencies | |
| working-directory: flutter_app | |
| run: flutter pub get | |
| - name: Build Windows Desktop | |
| working-directory: flutter_app | |
| run: flutter build windows --release | |
| - name: Upload Windows build artifact | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: flutter-windows-${{ github.run_number }} | |
| path: flutter_app/build/windows/x64/runner/Release/ | |
| retention-days: 1 | |
| unit-tests: | |
| name: Run Unit Tests | |
| runs-on: ubuntu-latest | |
| needs: lint | |
| timeout-minutes: 15 | |
| steps: | |
| - name: Checkout code | |
| uses: actions/checkout@v4 | |
| - name: Setup Flutter | |
| uses: subosito/flutter-action@v2 | |
| with: | |
| flutter-version: "3.x" | |
| channel: "stable" | |
| - name: Create .env file | |
| run: | | |
| echo "DITTO_APP_ID=test" > flutter_app/.env | |
| echo "DITTO_PLAYGROUND_TOKEN=test" >> flutter_app/.env | |
| echo "DITTO_AUTH_URL=test" >> flutter_app/.env | |
| echo "DITTO_WEBSOCKET_URL=test" >> flutter_app/.env | |
| - name: Get dependencies | |
| working-directory: flutter_app | |
| run: flutter pub get | |
| - name: Run tests | |
| working-directory: flutter_app | |
| run: | | |
| if [ -d "test" ]; then | |
| flutter test --coverage | |
| else | |
| echo "β οΈ No tests found. Skipping test execution." | |
| fi | |
| - name: Upload coverage reports | |
| if: always() | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: flutter-coverage-${{ github.run_number }} | |
| path: flutter_app/coverage/ | |
| retention-days: 1 | |
| browserstack-android: | |
| name: BrowserStack Android Testing | |
| runs-on: ubuntu-latest | |
| needs: [build-android] | |
| if: github.event_name == 'push' || github.event_name == 'pull_request' || github.event_name == 'workflow_dispatch' | |
| timeout-minutes: 150 | |
| outputs: | |
| build_id: ${{ steps.test.outputs.build_id }} | |
| steps: | |
| - name: Checkout code | |
| uses: actions/checkout@v4 | |
| - name: Setup Java | |
| uses: actions/setup-java@v4 | |
| with: | |
| distribution: "temurin" | |
| java-version: "17" | |
| - name: Setup Flutter | |
| uses: subosito/flutter-action@v2 | |
| with: | |
| flutter-version: "3.x" | |
| channel: "stable" | |
| - name: Create .env file | |
| run: | | |
| echo "DITTO_APP_ID=${{ secrets.DITTO_APP_ID }}" > flutter_app/.env | |
| echo "DITTO_PLAYGROUND_TOKEN=${{ secrets.DITTO_PLAYGROUND_TOKEN }}" >> flutter_app/.env | |
| echo "DITTO_AUTH_URL=${{ secrets.DITTO_AUTH_URL }}" >> flutter_app/.env | |
| echo "DITTO_WEBSOCKET_URL=${{ secrets.DITTO_WEBSOCKET_URL }}" >> flutter_app/.env | |
| - name: Cache Gradle dependencies | |
| uses: actions/cache@v4 | |
| with: | |
| path: | | |
| ~/.gradle/caches | |
| ~/.gradle/wrapper | |
| flutter_app/android/.gradle | |
| key: gradle-${{ runner.os }}-${{ hashFiles('flutter_app/android/gradle/wrapper/gradle-wrapper.properties', 'flutter_app/android/build.gradle.kts', 'flutter_app/android/app/build.gradle.kts') }} | |
| restore-keys: | | |
| gradle-${{ runner.os }}- | |
| - name: Get dependencies | |
| working-directory: flutter_app | |
| run: flutter pub get | |
| - name: Seed, build, upload and test on BrowserStack (with retry) | |
| id: test | |
| uses: nick-fields/retry@v3 | |
| with: | |
| max_attempts: 5 | |
| timeout_minutes: 20 | |
| retry_wait_seconds: 900 | |
| command: | | |
| # Seed test task to Ditto Cloud | |
| echo "Seeding test task to Ditto Cloud..." | |
| TIMESTAMP=$(date +%s) | |
| INVERTED_TIMESTAMP=$((9999999999 - TIMESTAMP)) | |
| SEED_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\": \"${INVERTED_TIMESTAMP}_flutter_ci_test_${{ github.run_id }}_${{ github.run_number }}\", | |
| \"title\": \"${INVERTED_TIMESTAMP}_flutter_ci_test_${{ github.run_id }}_${{ github.run_number }}\", | |
| \"done\": false, | |
| \"deleted\": false | |
| } | |
| } | |
| }" \ | |
| "https://${{ secrets.DITTO_API_URL }}/api/v4/store/execute") | |
| HTTP_CODE=$(echo "$SEED_RESPONSE" | tail -n1) | |
| BODY=$(echo "$SEED_RESPONSE" | sed '$d') | |
| if [ "$HTTP_CODE" -eq 200 ] || [ "$HTTP_CODE" -eq 201 ]; then | |
| TASK_TITLE="${INVERTED_TIMESTAMP}_flutter_ci_test_${{ github.run_id }}_${{ github.run_number }}" | |
| echo "Seeded task: $TASK_TITLE" | |
| else | |
| echo "Error: Failed to seed task. HTTP Status: $HTTP_CODE" | |
| echo "Response: $BODY" | |
| exit 1 | |
| fi | |
| # Build Android debug APKs with seeded task title | |
| echo "Building Android debug APKs with task title: $TASK_TITLE" | |
| cd flutter_app/android | |
| ENCODED_TASK=$(echo -n "DITTO_CLOUD_TASK_TITLE=$TASK_TITLE" | base64) | |
| TARGET_PATH="$(pwd)/../integration_test/app_test.dart" | |
| chmod +x gradlew | |
| # Clean build to ensure dart-defines are picked up | |
| ./gradlew clean | |
| ./gradlew assembleDebug assembleDebugAndroidTest \ | |
| -Ptarget="$TARGET_PATH" \ | |
| -Pdart-defines="SU5URUdSQVRJT05fVEVTVF9NT0RFPXRydWU=,$ENCODED_TASK" | |
| cd ../.. | |
| # Upload app APK to BrowserStack | |
| echo "Uploading app APK to BrowserStack..." | |
| APP_RESPONSE=$(curl -u "${{ secrets.BROWSERSTACK_USERNAME }}:${{ secrets.BROWSERSTACK_ACCESS_KEY }}" \ | |
| -X POST "https://api-cloud.browserstack.com/app-automate/flutter-integration-tests/v2/android/app" \ | |
| -F "file=@flutter_app/build/app/outputs/apk/debug/app-debug.apk") | |
| APP_URL=$(echo "$APP_RESPONSE" | jq -r .app_url) | |
| if [ "$APP_URL" = "null" ] || [ -z "$APP_URL" ]; then | |
| echo "Error: Failed to upload app APK" | |
| echo "Response: $APP_RESPONSE" | |
| exit 1 | |
| fi | |
| echo "App APK uploaded: $APP_URL" | |
| # Upload test suite APK to BrowserStack | |
| echo "Uploading test suite APK to BrowserStack..." | |
| TEST_RESPONSE=$(curl -u "${{ secrets.BROWSERSTACK_USERNAME }}:${{ secrets.BROWSERSTACK_ACCESS_KEY }}" \ | |
| -X POST "https://api-cloud.browserstack.com/app-automate/flutter-integration-tests/v2/android/test-suite" \ | |
| -F "file=@flutter_app/build/app/outputs/apk/androidTest/debug/app-debug-androidTest.apk") | |
| TEST_URL=$(echo "$TEST_RESPONSE" | jq -r .test_suite_url) | |
| if [ "$TEST_URL" = "null" ] || [ -z "$TEST_URL" ]; then | |
| echo "Error: Failed to upload test suite APK" | |
| echo "Response: $TEST_RESPONSE" | |
| exit 1 | |
| fi | |
| echo "Test suite APK uploaded: $TEST_URL" | |
| # Load devices from centralized config | |
| DEVICES=$(jq -c '.flutter.android.devices' .github/browserstack-devices.json) | |
| # Execute tests on BrowserStack | |
| BUILD_RESPONSE=$(curl -u "${{ secrets.BROWSERSTACK_USERNAME }}:${{ secrets.BROWSERSTACK_ACCESS_KEY }}" \ | |
| -X POST "https://api-cloud.browserstack.com/app-automate/flutter-integration-tests/v2/android/build" \ | |
| -H "Content-Type: application/json" \ | |
| -d "{ | |
| \"app\": \"$APP_URL\", | |
| \"testSuite\": \"$TEST_URL\", | |
| \"devices\": $DEVICES, | |
| \"project\": \"QuickStart Flutter\", | |
| \"buildName\": \"CI Build #${{ github.run_number }}\", | |
| \"buildTag\": \"${{ github.ref_name }}\", | |
| \"deviceLogs\": true, | |
| \"video\": true, | |
| \"networkLogs\": true, | |
| \"autoGrantPermissions\": true | |
| }") | |
| echo "BrowserStack API Response:" | |
| echo "$BUILD_RESPONSE" | |
| BUILD_ID=$(echo "$BUILD_RESPONSE" | jq -r .build_id) | |
| # Check if BUILD_ID is null or empty | |
| if [ "$BUILD_ID" = "null" ] || [ -z "$BUILD_ID" ]; then | |
| echo "Error: Failed to create BrowserStack build" | |
| echo "Response: $BUILD_RESPONSE" | |
| exit 1 | |
| fi | |
| echo "Build started with ID: $BUILD_ID" | |
| # Wait for BrowserStack tests to complete | |
| MAX_WAIT_TIME=1080 # 18 minutes | |
| CHECK_INTERVAL=30 # Check every 30 seconds | |
| ELAPSED=0 | |
| echo "β³ Waiting for test execution to complete..." | |
| while [ $ELAPSED -lt $MAX_WAIT_TIME ]; do | |
| RESPONSE=$(curl -s -u "${{ secrets.BROWSERSTACK_USERNAME }}:${{ secrets.BROWSERSTACK_ACCESS_KEY }}" \ | |
| "https://api-cloud.browserstack.com/app-automate/flutter-integration-tests/v2/android/builds/$BUILD_ID") | |
| STATUS=$(echo "$RESPONSE" | jq -r .status) | |
| if [ "$STATUS" = "null" ] || [ -z "$STATUS" ]; then | |
| echo "β οΈ API error, retrying... (${ELAPSED}s elapsed)" | |
| sleep $CHECK_INTERVAL | |
| ELAPSED=$((ELAPSED + CHECK_INTERVAL)) | |
| continue | |
| fi | |
| echo "π Status: $STATUS (${ELAPSED}s elapsed)" | |
| # Check for completion | |
| if [[ "$STATUS" =~ ^(done|failed|error|passed|completed)$ ]]; then | |
| echo "β Build completed with status: $STATUS" | |
| break | |
| fi | |
| sleep $CHECK_INTERVAL | |
| ELAPSED=$((ELAPSED + CHECK_INTERVAL)) | |
| done | |
| # Get final results | |
| FINAL_RESULT=$(curl -s -u "${{ secrets.BROWSERSTACK_USERNAME }}:${{ secrets.BROWSERSTACK_ACCESS_KEY }}" \ | |
| "https://api-cloud.browserstack.com/app-automate/flutter-integration-tests/v2/android/builds/$BUILD_ID") | |
| echo "π Final results:" | |
| echo "$FINAL_RESULT" | jq . | |
| # Validate and check results | |
| if echo "$FINAL_RESULT" | jq -e .devices > /dev/null 2>&1; then | |
| BUILD_STATUS=$(echo "$FINAL_RESULT" | jq -r .status) | |
| if [ "$BUILD_STATUS" != "passed" ]; then | |
| echo "β Tests failed with status: $BUILD_STATUS" | |
| FAILED_DEVICES=$(echo "$FINAL_RESULT" | jq -r '.devices[] | select(.sessions[].status != "passed") | .device') | |
| if [ -n "$FAILED_DEVICES" ]; then | |
| echo "Failed on devices: $FAILED_DEVICES" | |
| fi | |
| exit 1 | |
| else | |
| echo "π All tests passed successfully!" | |
| echo "Dashboard URL: https://app-automate.browserstack.com/dashboard/v2/builds/$BUILD_ID" | |
| fi | |
| else | |
| echo "β οΈ Could not parse final results" | |
| exit 1 | |
| fi | |
| - name: Add to GitHub Actions Summary | |
| if: always() | |
| run: | | |
| BUILD_ID="${{ steps.test.outputs.build_id }}" | |
| echo "### π€ BrowserStack Android Results" >> $GITHUB_STEP_SUMMARY | |
| echo "" >> $GITHUB_STEP_SUMMARY | |
| if [ "${{ job.status }}" = "success" ]; then | |
| echo "β **Status:** Passed" >> $GITHUB_STEP_SUMMARY | |
| else | |
| echo "β **Status:** Failed" >> $GITHUB_STEP_SUMMARY | |
| fi | |
| echo "- **Test Task:** ${{ steps.seed_task.outputs.document-title }}" >> $GITHUB_STEP_SUMMARY | |
| echo "- **Build ID:** $BUILD_ID" >> $GITHUB_STEP_SUMMARY | |
| echo "- **Dashboard:** [View Results](https://app-automate.browserstack.com/dashboard/v2/builds/$BUILD_ID)" >> $GITHUB_STEP_SUMMARY | |
| echo "" >> $GITHUB_STEP_SUMMARY | |
| browserstack-ios: | |
| name: BrowserStack iOS Testing | |
| runs-on: macos-latest | |
| needs: [build-ios] | |
| if: github.event_name == 'push' || github.event_name == 'pull_request' || github.event_name == 'workflow_dispatch' | |
| timeout-minutes: 150 | |
| steps: | |
| - name: Checkout code | |
| uses: actions/checkout@v4 | |
| - name: Setup Flutter | |
| uses: subosito/flutter-action@v2 | |
| with: | |
| flutter-version: "3.x" | |
| channel: "stable" | |
| - name: Create .env file | |
| run: | | |
| echo "DITTO_APP_ID=${{ secrets.DITTO_APP_ID }}" > flutter_app/.env | |
| echo "DITTO_PLAYGROUND_TOKEN=${{ secrets.DITTO_PLAYGROUND_TOKEN }}" >> flutter_app/.env | |
| echo "DITTO_AUTH_URL=${{ secrets.DITTO_AUTH_URL }}" >> flutter_app/.env | |
| echo "DITTO_WEBSOCKET_URL=${{ secrets.DITTO_WEBSOCKET_URL }}" >> flutter_app/.env | |
| - name: Get dependencies | |
| working-directory: flutter_app | |
| run: flutter pub get | |
| - name: Install CocoaPods | |
| working-directory: flutter_app/ios | |
| run: pod install || pod install --repo-update | |
| - name: Seed, build, upload and test on BrowserStack (with retry) | |
| id: test | |
| uses: nick-fields/retry@v3 | |
| with: | |
| max_attempts: 5 | |
| timeout_minutes: 20 | |
| retry_wait_seconds: 900 | |
| command: | | |
| # Seed test task to Ditto Cloud | |
| echo "Seeding test task to Ditto Cloud..." | |
| TIMESTAMP=$(date +%s) | |
| INVERTED_TIMESTAMP=$((9999999999 - TIMESTAMP)) | |
| SEED_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\": \"${INVERTED_TIMESTAMP}_flutter_ci_test_${{ github.run_id }}_${{ github.run_number }}\", | |
| \"title\": \"${INVERTED_TIMESTAMP}_flutter_ci_test_${{ github.run_id }}_${{ github.run_number }}\", | |
| \"done\": false, | |
| \"deleted\": false | |
| } | |
| } | |
| }" \ | |
| "https://${{ secrets.DITTO_API_URL }}/api/v4/store/execute") | |
| HTTP_CODE=$(echo "$SEED_RESPONSE" | tail -n1) | |
| BODY=$(echo "$SEED_RESPONSE" | sed '$d') | |
| if [ "$HTTP_CODE" -eq 200 ] || [ "$HTTP_CODE" -eq 201 ]; then | |
| TASK_TITLE="${INVERTED_TIMESTAMP}_flutter_ci_test_${{ github.run_id }}_${{ github.run_number }}" | |
| echo "Seeded task: $TASK_TITLE" | |
| else | |
| echo "Error: Failed to seed task. HTTP Status: $HTTP_CODE" | |
| echo "Response: $BODY" | |
| exit 1 | |
| fi | |
| # Build iOS integration test with seeded task title | |
| echo "π§ͺ Building iOS integration test package with task title: $TASK_TITLE" | |
| cd flutter_app | |
| flutter build ios integration_test/app_test.dart --release --no-codesign \ | |
| --dart-define=INTEGRATION_TEST_MODE=true \ | |
| --dart-define="DITTO_CLOUD_TASK_TITLE=$TASK_TITLE" | |
| # Create iOS test package | |
| cd .. | |
| output="flutter_app/build/ios_integration" | |
| product="$output/Build/Products" | |
| pushd flutter_app/ios | |
| xcodebuild -workspace Runner.xcworkspace \ | |
| -scheme Runner \ | |
| -config Flutter/Release.xcconfig \ | |
| -derivedDataPath "../../build/ios_integration" \ | |
| -sdk iphoneos \ | |
| build-for-testing \ | |
| CODE_SIGNING_ALLOWED=NO | |
| popd | |
| # Create test package zip from xctestrun and app bundle | |
| pushd "$product" || exit 1 | |
| XCTESTRUN_FILE=$(find . -name "*.xctestrun" -type f | head -1) | |
| if [ -z "$XCTESTRUN_FILE" ]; then | |
| echo "β No .xctestrun file found" | |
| exit 1 | |
| fi | |
| echo "π¦ Found xctestrun file: $XCTESTRUN_FILE" | |
| zip -r "ios_test_package.zip" "Release-iphoneos" "$XCTESTRUN_FILE" | |
| popd | |
| # Upload iOS test package to BrowserStack | |
| echo "π€ Uploading iOS test package to BrowserStack..." | |
| TEST_RESPONSE=$(curl -u "${{ secrets.BROWSERSTACK_USERNAME }}:${{ secrets.BROWSERSTACK_ACCESS_KEY }}" \ | |
| -X POST "https://api-cloud.browserstack.com/app-automate/flutter-integration-tests/v2/ios/test-package" \ | |
| -F "file=@flutter_app/build/ios_integration/Build/Products/ios_test_package.zip") | |
| echo "Upload response: $TEST_RESPONSE" | |
| TEST_PACKAGE_URL=$(echo "$TEST_RESPONSE" | jq -r .test_package_url) | |
| if [ "$TEST_PACKAGE_URL" = "null" ] || [ -z "$TEST_PACKAGE_URL" ]; then | |
| echo "β Failed to upload iOS test package" | |
| echo "Response: $TEST_RESPONSE" | |
| exit 1 | |
| fi | |
| echo "β iOS test package uploaded: $TEST_PACKAGE_URL" | |
| # Execute BrowserStack tests | |
| echo "Test Package URL: $TEST_PACKAGE_URL" | |
| # Create test execution request | |
| # NOTE: Flutter testing framework requires iOS 15+ due to _backtrace_async symbol | |
| # See: https://developer.apple.com/documentation/os/backtrace_async | |
| # Load devices from centralized config | |
| DEVICES=$(jq -c '.flutter.ios.devices' .github/browserstack-devices.json) | |
| BUILD_RESPONSE=$(curl -u "${{ secrets.BROWSERSTACK_USERNAME }}:${{ secrets.BROWSERSTACK_ACCESS_KEY }}" \ | |
| -X POST "https://api-cloud.browserstack.com/app-automate/flutter-integration-tests/v2/ios/build" \ | |
| -H "Content-Type: application/json" \ | |
| -d "{ | |
| \"testPackage\": \"$TEST_PACKAGE_URL\", | |
| \"devices\": $DEVICES, | |
| \"project\": \"QuickStart Flutter\", | |
| \"buildName\": \"CI Build #${{ github.run_number }}\", | |
| \"buildTag\": \"${{ github.ref_name }}\", | |
| \"deviceLogs\": true, | |
| \"networkLogs\": true, | |
| \"autoGrantPermissions\": true | |
| }") | |
| echo "BrowserStack API Response:" | |
| echo "$BUILD_RESPONSE" | |
| BUILD_ID=$(echo "$BUILD_RESPONSE" | jq -r .build_id) | |
| # Check if BUILD_ID is null or empty | |
| if [ "$BUILD_ID" = "null" ] || [ -z "$BUILD_ID" ]; then | |
| echo "Error: Failed to create BrowserStack build" | |
| echo "Response: $BUILD_RESPONSE" | |
| exit 1 | |
| fi | |
| echo "Build started with ID: $BUILD_ID" | |
| # Wait for BrowserStack tests to complete | |
| MAX_WAIT_TIME=1080 # 18 minutes | |
| CHECK_INTERVAL=30 # Check every 30 seconds | |
| ELAPSED=0 | |
| echo "β³ Waiting for test execution to complete..." | |
| while [ $ELAPSED -lt $MAX_WAIT_TIME ]; do | |
| RESPONSE=$(curl -s -u "${{ secrets.BROWSERSTACK_USERNAME }}:${{ secrets.BROWSERSTACK_ACCESS_KEY }}" \ | |
| "https://api-cloud.browserstack.com/app-automate/flutter-integration-tests/v2/ios/builds/$BUILD_ID") | |
| STATUS=$(echo "$RESPONSE" | jq -r .status) | |
| if [ "$STATUS" = "null" ] || [ -z "$STATUS" ]; then | |
| echo "β οΈ API error, retrying... (${ELAPSED}s elapsed)" | |
| sleep $CHECK_INTERVAL | |
| ELAPSED=$((ELAPSED + CHECK_INTERVAL)) | |
| continue | |
| fi | |
| echo "π Status: $STATUS (${ELAPSED}s elapsed)" | |
| # Check for completion | |
| if [[ "$STATUS" =~ ^(done|failed|error|passed|completed)$ ]]; then | |
| echo "β Build completed with status: $STATUS" | |
| break | |
| fi | |
| sleep $CHECK_INTERVAL | |
| ELAPSED=$((ELAPSED + CHECK_INTERVAL)) | |
| done | |
| # Get final results | |
| FINAL_RESULT=$(curl -s -u "${{ secrets.BROWSERSTACK_USERNAME }}:${{ secrets.BROWSERSTACK_ACCESS_KEY }}" \ | |
| "https://api-cloud.browserstack.com/app-automate/flutter-integration-tests/v2/ios/builds/$BUILD_ID") | |
| echo "π Final results:" | |
| echo "$FINAL_RESULT" | jq . | |
| # Validate and check results | |
| if echo "$FINAL_RESULT" | jq -e .devices > /dev/null 2>&1; then | |
| BUILD_STATUS=$(echo "$FINAL_RESULT" | jq -r .status) | |
| if [ "$BUILD_STATUS" != "passed" ]; then | |
| echo "β Tests failed with status: $BUILD_STATUS" | |
| FAILED_DEVICES=$(echo "$FINAL_RESULT" | jq -r '.devices[] | select(.sessions[].status != "passed") | .device') | |
| if [ -n "$FAILED_DEVICES" ]; then | |
| echo "Failed on devices: $FAILED_DEVICES" | |
| fi | |
| exit 1 | |
| else | |
| echo "π All tests passed successfully!" | |
| echo "Dashboard URL: https://app-automate.browserstack.com/dashboard/v2/builds/$BUILD_ID" | |
| fi | |
| else | |
| echo "β οΈ Could not parse final results" | |
| exit 1 | |
| fi | |
| - name: Add to GitHub Actions Summary | |
| if: always() | |
| run: | | |
| BUILD_ID="${{ steps.test.outputs.build_id }}" | |
| echo "### π BrowserStack iOS Results" >> $GITHUB_STEP_SUMMARY | |
| echo "" >> $GITHUB_STEP_SUMMARY | |
| if [ "${{ job.status }}" = "success" ]; then | |
| echo "β **Status:** Passed" >> $GITHUB_STEP_SUMMARY | |
| else | |
| echo "β **Status:** Failed" >> $GITHUB_STEP_SUMMARY | |
| fi | |
| echo "- **Test Task:** ${{ steps.seed_task.outputs.document-title }}" >> $GITHUB_STEP_SUMMARY | |
| echo "- **Build ID:** $BUILD_ID" >> $GITHUB_STEP_SUMMARY | |
| echo "- **Dashboard:** [View Results](https://app-automate.browserstack.com/dashboard/v2/builds/$BUILD_ID)" >> $GITHUB_STEP_SUMMARY | |
| echo "" >> $GITHUB_STEP_SUMMARY | |
| echo "---" >> $GITHUB_STEP_SUMMARY | |
| echo "" >> $GITHUB_STEP_SUMMARY | |
| summary: | |
| name: Test Summary | |
| runs-on: ubuntu-latest | |
| needs: | |
| [ | |
| lint, | |
| unit-tests, | |
| build-android, | |
| build-ios, | |
| build-web, | |
| build-macos, | |
| build-windows, | |
| browserstack-android, | |
| browserstack-ios, | |
| ] | |
| if: always() | |
| steps: | |
| - name: Create Overall Summary | |
| run: | | |
| echo "## π BrowserStack Test Summary" >> $GITHUB_STEP_SUMMARY | |
| echo "" >> $GITHUB_STEP_SUMMARY | |
| # Check overall status | |
| if [ "${{ needs.browserstack-android.result }}" = "success" ] && [ "${{ needs.browserstack-ios.result }}" = "success" ]; then | |
| echo "π **Overall Status:** All tests passed!" >> $GITHUB_STEP_SUMMARY | |
| elif [ "${{ needs.browserstack-android.result }}" = "skipped" ] || [ "${{ needs.browserstack-ios.result }}" = "skipped" ]; then | |
| echo "β οΈ **Overall Status:** Some tests were skipped" >> $GITHUB_STEP_SUMMARY | |
| else | |
| echo "β **Overall Status:** Some tests failed" >> $GITHUB_STEP_SUMMARY | |
| fi | |
| echo "" >> $GITHUB_STEP_SUMMARY | |
| echo "### Results:" >> $GITHUB_STEP_SUMMARY | |
| echo "- **Android:** ${{ needs.browserstack-android.result }}" >> $GITHUB_STEP_SUMMARY | |
| echo "- **iOS:** ${{ needs.browserstack-ios.result }}" >> $GITHUB_STEP_SUMMARY |