diff --git a/.github/actions/generate-browserstack-names/action.yml b/.github/actions/generate-browserstack-names/action.yml new file mode 100644 index 000000000..be815849f --- /dev/null +++ b/.github/actions/generate-browserstack-names/action.yml @@ -0,0 +1,63 @@ +name: 'Generate BrowserStack Names' +description: 'Auto-generates BrowserStack project name from workflow context' +inputs: + platform-suffix: + description: 'Optional platform suffix to append to build name (e.g., " (Android)", " (iOS)")' + required: false + default: '' +outputs: + project-name: + description: 'BrowserStack project name (auto-derived from workflow)' + value: ${{ steps.generate.outputs.project-name }} + build-name: + description: 'BrowserStack build name (currently returns empty - FIXME)' + value: ${{ steps.generate.outputs.build-name }} + +runs: + using: 'composite' + steps: + - name: Generate BrowserStack names + id: generate + shell: bash + run: | + # Auto-derive project name from workflow file name + # Pattern: -ci.yml -> quickstart - + WORKFLOW_FILE="${{ github.workflow }}" + + echo "๐Ÿ” Workflow: $WORKFLOW_FILE" + + # Extract platform name from workflow name and convert to title case + # Examples: + # "JavaScript Web CI" -> "JavaScript Web" + # "Android Kotlin CI" -> "Android Kotlin" + # "React Native Expo CI" -> "React Native Expo" + PLATFORM_NAME=$(echo "$WORKFLOW_FILE" | sed 's/ CI$//') + + # Special case mappings for non-standard names + case "$PLATFORM_NAME" in + "Android C++") + PLATFORM_NAME="Android CPP" + ;; + esac + + PROJECT_NAME="quickstart - $PLATFORM_NAME" + + # FIXME: Build name generation is currently broken and doesn't work properly + # For now, we return an empty string. This needs to be fixed in a future PR. + # The build name should ideally include PR context and commit info, but the + # current implementation using GitHub CLI and event context is unreliable. + # + # TODO: Implement proper build name generation that: + # 1. Reliably fetches PR information + # 2. Sanitizes strings for BrowserStack requirements + # 3. Handles both PR and non-PR contexts + # 4. Includes platform suffix when provided + BUILD_NAME="" + + # Output values + echo "project-name=$PROJECT_NAME" >> $GITHUB_OUTPUT + echo "build-name=$BUILD_NAME" >> $GITHUB_OUTPUT + + # Debug output + echo "๐Ÿ“ฆ Project: $PROJECT_NAME" + echo "๐Ÿ—๏ธ Build: $BUILD_NAME (empty - FIXME)" diff --git a/.github/browserstack-devices.yml b/.github/browserstack-devices.yml new file mode 100644 index 000000000..cf5a53643 --- /dev/null +++ b/.github/browserstack-devices.yml @@ -0,0 +1,115 @@ +--- +# BrowserStack Device Configuration +# +# Centralized configuration for all BrowserStack testing across CI workflows. +# Each SDK/app has its own configuration section below. +# +# Device Selection Criteria: +# - Coverage: Test across multiple OS versions (latest, current-1, current-2) +# - Market share: Focus on popular devices (Pixel, Samsung Galaxy, iPhone) +# - OS baseline: Ensure minimum supported OS versions are tested +# +# References: +# - Mobile devices: https://www.browserstack.com/docs/app-automate/api-reference/appium/devices +# - Desktop browsers: https://www.browserstack.com/docs/automate/api-reference/selenium/list-of-browsers-and-platforms + +# Android C++ QuickStart +android-cpp: + devices: + - "Google Pixel 7-13.0" # Android 13 baseline + +# Android Java QuickStart +android-java: + devices: + - "Google Pixel 8-14.0" # Latest Android 14 + - "Samsung Galaxy S23-13.0" # Popular flagship, Android 13 + - "Google Pixel 6-12.0" # Android 12 support + - "OnePlus 9-11.0" # Android 11 minimum baseline + +# Android Kotlin QuickStart +android-kotlin: + devices: + - "Google Pixel 8-14.0" # Latest Android 14 + - "Samsung Galaxy S23-13.0" # Popular flagship, Android 13 + - "Google Pixel 6-12.0" # Android 12 support + - "OnePlus 9-11.0" # Android 11 minimum baseline + +# Flutter QuickStart (multi-platform) +flutter: + android: + devices: + - "Google Pixel 7-13.0" # Android 13 baseline + - "Samsung Galaxy S23-13.0" # Popular flagship + - "Google Pixel 6-12.0" # Android 12 support + ios: + devices: + - "iPhone 13-15" # iOS 15 support + - "iPhone 14-16" # iOS 16 current + - "iPhone 12-17" # iOS 17 latest + +# Kotlin Multiplatform QuickStart +kotlin-multiplatform: + android: + devices: + - "Google Pixel 8-14.0" # Latest Android 14 + - "Samsung Galaxy S23-13.0" # Popular flagship + +# React Native QuickStart +react-native: + android: + devices: + - "Samsung Galaxy S22-12.0" # Android 12 + - "Google Pixel 7-13.0" # Android 13 + ios: + devices: + - "iPhone 15-17.0" # Latest iOS 17 + - "iPhone 14-16.0" # iOS 16 support + +# React Native Expo QuickStart +react-native-expo: + android: + devices: + - "Samsung Galaxy S22-12.0" # Android 12 + - "Google Pixel 7-13.0" # Android 13 + ios: + devices: + - "iPhone 15-17.0" # Latest iOS 17 + - "iPhone 14-16.0" # iOS 16 support + +# .NET MAUI QuickStart (multi-platform) +dotnet-maui: + android: + devices: + - "Google Pixel 8-14.0" # Latest Android 14 + - "Samsung Galaxy S23-13.0" # Popular flagship + ios: + devices: + - "iPhone 15-17.0" # Latest iOS 17 + - "iPhone 14-16.0" # iOS 16 support + +# Swift/iOS QuickStart +swift: + devices: + - "iPhone 15 Pro-17" # Latest flagship with iOS 17 + +# Java Spring (web application) +# Tests web app in desktop browsers using Selenium WebDriver +java-spring: + platforms: + - os: "Windows" + osVersion: "11" + browserName: "Chrome" + browserVersion: "latest" # Always test latest Chrome + +# JavaScript Web (React web application) +# Tests web app in multiple desktop browsers +javascript-web: + browsers: + - browser: "Chrome" + browser_version: "120.0" + os: "Windows" + os_version: "11" + - browser: "Firefox" + browser_version: "121.0" + os: "Windows" + os_version: "11" diff --git a/.github/scripts/browserstack-test.py b/.github/scripts/browserstack-test.py index 774348941..24c8265ab 100644 --- a/.github/scripts/browserstack-test.py +++ b/.github/scripts/browserstack-test.py @@ -7,9 +7,9 @@ """ import time -import json import sys import os +import yaml from selenium import webdriver from selenium.webdriver.common.by import By from selenium.webdriver.support.ui import WebDriverWait @@ -58,14 +58,18 @@ def run_test(browser_config): f"Starting test on {browser_config['browser']} {browser_config['browser_version']} on {browser_config['os']}" ) - # Set up BrowserStack options + # Get BrowserStack project and build names from environment + # These are now generated by the get-browserstack-build-info action + project_name = os.environ.get('BROWSERSTACK_PROJECT', 'quickstart - JavaScript Web') + build_name = os.environ.get('BROWSERSTACK_BUILD_NAME', f"Build-{os.environ.get('GITHUB_RUN_NUMBER', '0')}") + bs_options = { "browserVersion": browser_config["browser_version"], "os": browser_config["os"], "osVersion": browser_config["os_version"], "sessionName": f"Ditto Tasks Test - {browser_config['browser']} {browser_config['browser_version']}", - "buildName": f"Ditto JavaScript Web Build #{os.environ.get('GITHUB_RUN_NUMBER', '0')}", - "projectName": "Ditto JavaScript Web", + "buildName": build_name, + "projectName": project_name, "local": "true", "debug": "true", "video": "true", @@ -125,7 +129,7 @@ def run_test(browser_config): time.sleep(3) # Check for GitHub test document - github_doc_id = os.environ.get("GITHUB_TEST_DOC_ID") + github_doc_id = os.environ.get("DITTO_CLOUD_TASK_TITLE") if github_doc_id: print(f"Checking for GitHub test document: {github_doc_id}") if wait_for_sync_document(driver, github_doc_id): @@ -226,21 +230,13 @@ def run_test(browser_config): def main(): """Main function to run all browser tests.""" - # Browser configurations to test - browsers = [ - { - "browser": "Chrome", - "browser_version": "120.0", - "os": "Windows", - "os_version": "11", - }, - { - "browser": "Firefox", - "browser_version": "121.0", - "os": "Windows", - "os_version": "11", - }, - ] + # Load browser configurations from centralized config + config_path = os.path.join( + os.path.dirname(__file__), "..", "browserstack-devices.yml" + ) + with open(config_path, "r") as f: + config = yaml.safe_load(f) + browsers = config["javascript-web"]["browsers"] # Run tests on all browsers results = [] diff --git a/.github/scripts/retry-browserstack.sh b/.github/scripts/retry-browserstack.sh deleted file mode 100755 index 4aaed4e94..000000000 --- a/.github/scripts/retry-browserstack.sh +++ /dev/null @@ -1,79 +0,0 @@ -#!/bin/bash -# -# Generic retry wrapper for BrowserStack API calls -# Retries on BROWSERSTACK_ALL_PARALLELS_IN_USE errors with exponential backoff -# -# Usage: .github/scripts/retry-browserstack.sh '' -# - -set -e - -# Configuration -MAX_ATTEMPTS=${MAX_ATTEMPTS:-5} -INITIAL_WAIT=${INITIAL_WAIT:-60} # Start with 1 minute -MAX_WAIT=${MAX_WAIT:-300} # Max 5 minutes between retries - -# Function to run command with retry logic -retry_on_queue_full() { - local attempt=1 - local wait_time=$INITIAL_WAIT - - while [ $attempt -le $MAX_ATTEMPTS ]; do - echo "๐Ÿ”„ Attempt $attempt/$MAX_ATTEMPTS..." - - # Run the command and capture output - set +e - OUTPUT=$(eval "$@" 2>&1) - EXIT_CODE=$? - set -e - - echo "$OUTPUT" - - # Check if it's a BrowserStack queue error - if echo "$OUTPUT" | grep -q "BROWSERSTACK_ALL_PARALLELS_IN_USE"; then - if [ $attempt -lt $MAX_ATTEMPTS ]; then - echo "โณ BrowserStack queue is full. Waiting ${wait_time}s before retry (attempt $attempt/$MAX_ATTEMPTS)..." - sleep $wait_time - - # Exponential backoff with max cap - wait_time=$((wait_time * 2)) - if [ $wait_time -gt $MAX_WAIT ]; then - wait_time=$MAX_WAIT - fi - - attempt=$((attempt + 1)) - else - echo "โŒ Max attempts ($MAX_ATTEMPTS) reached. BrowserStack queue still full." - return 1 - fi - else - # Not a queue error - either success or fail immediately - if [ $EXIT_CODE -eq 0 ]; then - echo "โœ… Command succeeded!" - else - echo "โŒ Command failed with non-queue error" - fi - return $EXIT_CODE - fi - done - - return 1 -} - -# Check if command was provided -if [ $# -eq 0 ]; then - echo "Usage: $0 ''" - echo "" - echo "Examples:" - echo " $0 'curl -u \$USER:\$KEY -X POST https://api.browserstack.com/...'" - echo " $0 './gradlew test'" - echo "" - echo "Environment variables:" - echo " MAX_ATTEMPTS=$MAX_ATTEMPTS (default: 5)" - echo " INITIAL_WAIT=$INITIAL_WAIT (default: 60 seconds)" - echo " MAX_WAIT=$MAX_WAIT (default: 300 seconds)" - exit 1 -fi - -# Run with retry logic -retry_on_queue_full "$@" diff --git a/.github/workflows/android-cpp-ci.yml b/.github/workflows/android-cpp-ci.yml index 3cc56b452..39fc2ddd8 100644 --- a/.github/workflows/android-cpp-ci.yml +++ b/.github/workflows/android-cpp-ci.yml @@ -7,20 +7,10 @@ name: Android C++ CI on: pull_request: - branches: - - main - - 'sdk-*' paths: - - 'android-cpp/**' - - '.github/workflows/android-cpp-ci.yml' - push: - branches: - - main - - 'sdk-*' - paths: - - 'android-cpp/**' - - '.github/workflows/android-cpp-ci.yml' - workflow_dispatch: # Allow manual trigger + - "android-cpp/**" + - ".github/workflows/android-cpp-ci.yml" + workflow_dispatch: concurrency: group: ${{ github.workflow }}-${{ github.ref }} @@ -33,207 +23,266 @@ jobs: timeout-minutes: 10 steps: - - uses: actions/checkout@v4 - - - name: Setup Java - uses: actions/setup-java@v4 - with: - distribution: 'temurin' - java-version: '17' - - - name: Install clang-format - run: | - sudo apt-get update - sudo apt-get install -y clang-format - - - name: Cache Gradle dependencies - uses: actions/cache@v4 - with: - path: | - ~/.gradle/caches - ~/.gradle/wrapper - android-cpp/.gradle - key: gradle-${{ runner.os }}-${{ hashFiles('android-cpp/QuickStartTasksCPP/gradle/wrapper/gradle-wrapper.properties', 'android-cpp/QuickStartTasksCPP/**/*.gradle*') }} - restore-keys: | - gradle-${{ runner.os }}- - - - name: Create test .env file - run: | - echo "DITTO_APP_ID=test" > .env - echo "DITTO_PLAYGROUND_TOKEN=test" >> .env - echo "DITTO_AUTH_URL=test" >> .env - echo "DITTO_WEBSOCKET_URL=test" >> .env - - - name: Run Android linting - working-directory: android-cpp/QuickStartTasksCPP - run: ./gradlew lint - - - name: Check C++ formatting - working-directory: android-cpp/QuickStartTasksCPP/app/src/main/cpp - run: | - echo "Checking C++ code formatting with clang-format..." - find . -name "*.cpp" -o -name "*.h" | xargs clang-format --dry-run --Werror - if [ $? -eq 0 ]; then - echo "โœ… C++ code formatting check passed" - else - echo "โŒ C++ code formatting issues found. Run clang-format to fix." - exit 1 - fi + - uses: actions/checkout@v4 + + - name: Setup Java + uses: actions/setup-java@v4 + with: + distribution: "temurin" + java-version: "17" + + - name: Install clang-format + run: | + sudo apt-get update + sudo apt-get install -y clang-format + + - name: Cache Gradle dependencies + uses: actions/cache@v4 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + android-cpp/.gradle + key: gradle-${{ runner.os }}-${{ hashFiles('android-cpp/QuickStartTasksCPP/gradle/wrapper/gradle-wrapper.properties', 'android-cpp/QuickStartTasksCPP/**/*.gradle*') }} + restore-keys: | + gradle-${{ runner.os }}- + + - name: Create test .env file + run: | + echo "DITTO_APP_ID=test" > .env + echo "DITTO_PLAYGROUND_TOKEN=test" >> .env + echo "DITTO_AUTH_URL=test" >> .env + echo "DITTO_WEBSOCKET_URL=test" >> .env + + - name: Run Android linting + working-directory: android-cpp/QuickStartTasksCPP + run: ./gradlew lint + + - name: Check C++ formatting + working-directory: android-cpp/QuickStartTasksCPP/app/src/main/cpp + run: | + echo "Checking C++ code formatting with clang-format..." + find . -name "*.cpp" -o -name "*.h" | xargs clang-format --dry-run --Werror + if [ $? -eq 0 ]; then + echo "โœ… C++ code formatting check passed" + else + echo "โŒ C++ code formatting issues found. Run clang-format to fix." + exit 1 + fi build: name: Build APK runs-on: ubuntu-latest needs: lint timeout-minutes: 20 - + steps: - - uses: actions/checkout@v4 - - - 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: Setup Gradle - uses: gradle/actions/setup-gradle@v3 - - - name: Cache Gradle dependencies - uses: actions/cache@v4 - with: - path: | - ~/.gradle/caches - ~/.gradle/wrapper - key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} - restore-keys: | - ${{ runner.os }}-gradle- - - - name: Create .env file - run: | - echo "DITTO_APP_ID=${{ secrets.DITTO_APP_ID }}" > .env - echo "DITTO_PLAYGROUND_TOKEN=${{ secrets.DITTO_PLAYGROUND_TOKEN }}" >> .env - echo "DITTO_AUTH_URL=${{ secrets.DITTO_AUTH_URL }}" >> .env - echo "DITTO_WEBSOCKET_URL=${{ secrets.DITTO_WEBSOCKET_URL }}" >> .env - - - name: Build APK - working-directory: android-cpp/QuickStartTasksCPP - run: ./gradlew assembleRelease - - - name: Upload APK artifacts - uses: actions/upload-artifact@v4 - with: - name: android-cpp-apks - path: | - android-cpp/QuickStartTasksCPP/app/build/outputs/apk/release/app-release-unsigned.apk - retention-days: 1 - - browserstack-appium-test: - name: BrowserStack Device Testing + - uses: actions/checkout@v4 + + - 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: Setup Gradle + uses: gradle/actions/setup-gradle@v3 + + - name: Cache Gradle dependencies + uses: actions/cache@v4 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} + restore-keys: | + ${{ runner.os }}-gradle- + + - name: Create .env file + run: | + echo "DITTO_APP_ID=${{ secrets.DITTO_APP_ID }}" > .env + echo "DITTO_PLAYGROUND_TOKEN=${{ secrets.DITTO_PLAYGROUND_TOKEN }}" >> .env + echo "DITTO_AUTH_URL=${{ secrets.DITTO_AUTH_URL }}" >> .env + echo "DITTO_WEBSOCKET_URL=${{ secrets.DITTO_WEBSOCKET_URL }}" >> .env + + - name: Build APK + working-directory: android-cpp/QuickStartTasksCPP + run: ./gradlew assembleRelease + + - name: Upload APK artifacts + uses: actions/upload-artifact@v4 + with: + name: android-cpp-apks + path: | + android-cpp/QuickStartTasksCPP/app/build/outputs/apk/release/app-release-unsigned.apk + retention-days: 1 + + browserstack-android: + name: BrowserStack Android Testing runs-on: ubuntu-latest needs: [build] if: github.event_name == 'push' || github.event_name == 'pull_request' || github.event_name == 'workflow_dispatch' - timeout-minutes: 45 + timeout-minutes: 150 + outputs: + build_id: ${{ steps.test.outputs.build_id }} steps: - - uses: actions/checkout@v4 - - - name: Seed test task to Ditto Cloud - id: seed_task - uses: ./.github/actions/seed-ditto-document - with: - ditto-api-key: ${{ secrets.DITTO_API_KEY }} - ditto-api-url: ${{ secrets.DITTO_API_URL }} - app-name: 'android-cpp' - - - name: Download APK artifacts - uses: actions/download-artifact@v4 - with: - name: android-cpp-apks - path: android-cpp/QuickStartTasksCPP/app/build/outputs/apk/release/ - - - name: Setup Java for Appium test - uses: actions/setup-java@v4 - with: - distribution: 'temurin' - java-version: '17' - - - - name: Upload APK to BrowserStack - id: upload - run: | - CREDS="${{ secrets.BROWSERSTACK_USERNAME }}:${{ secrets.BROWSERSTACK_ACCESS_KEY }}" - - APP_RESPONSE=$(curl -u "$CREDS" \ - -X POST "https://api-cloud.browserstack.com/app-automate/upload" \ - -F "file=@android-cpp/QuickStartTasksCPP/app/build/outputs/apk/release/app-release-unsigned.apk" \ - -F "custom_id=ditto-android-cpp-appium-app") - - APP_URL=$(echo "$APP_RESPONSE" | jq -r .app_url) - echo "app_url=$APP_URL" >> "$GITHUB_OUTPUT" - - if [ "$APP_URL" = "null" ] || [ -z "$APP_URL" ]; then - echo "Failed to upload app APK" - exit 1 - fi - - - name: Execute Appium tests on BrowserStack - id: test - working-directory: android-cpp/QuickStartTasksCPP/appium-test - run: | - export BROWSERSTACK_USERNAME="${{ secrets.BROWSERSTACK_USERNAME }}" - export BROWSERSTACK_ACCESS_KEY="${{ secrets.BROWSERSTACK_ACCESS_KEY }}" - export BROWSERSTACK_APP_URL="${{ steps.upload.outputs.app_url }}" - export GITHUB_TEST_DOC_ID="${{ steps.seed_task.outputs.document-title }}" - - ../gradlew test --console=plain --no-daemon - - - name: Upload test artifacts - if: always() - uses: actions/upload-artifact@v4 - with: - name: test-results - path: | - android-cpp/QuickStartTasksCPP/app/build/outputs/apk/ - android-cpp/QuickStartTasksCPP/appium-test/build/reports/ + - uses: actions/checkout@v4 + + - name: Download APK artifacts + uses: actions/download-artifact@v4 + with: + name: android-cpp-apks + path: android-cpp/QuickStartTasksCPP/app/build/outputs/apk/release/ + + - name: Setup Java for Appium test + uses: actions/setup-java@v4 + with: + distribution: "temurin" + java-version: "17" + + - name: Upload APK to BrowserStack + id: upload + run: | + echo "Uploading APK to BrowserStack..." + CREDS="${{ secrets.BROWSERSTACK_USERNAME }}:${{ secrets.BROWSERSTACK_ACCESS_KEY }}" + APP_RESPONSE=$(curl -u "$CREDS" \ + -X POST "https://api-cloud.browserstack.com/app-automate/upload" \ + -F "file=@android-cpp/QuickStartTasksCPP/app/build/outputs/apk/release/app-release-unsigned.apk" \ + -F "custom_id=ditto-android-cpp-appium-app") + + APP_URL=$(echo "$APP_RESPONSE" | yq eval -p=json .app_url) + if [ "$APP_URL" = "null" ] || [ -z "$APP_URL" ]; then + echo "Error: Failed to upload APK" + exit 1 + fi + echo "app_url=$APP_URL" >> "$GITHUB_OUTPUT" + echo "APK uploaded: $APP_URL" + + - name: Get BrowserStack build info + id: build-info + uses: ./.github/actions/generate-browserstack-names + with: + + - name: Seed and execute Appium tests on BrowserStack + 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}_android-cpp_ci_test_${{ github.run_id }}_${{ github.run_number }}\", + \"title\": \"${INVERTED_TIMESTAMP}_android-cpp_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}_android-cpp_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 + + # Load device from centralized config + DEVICE=$(yq eval '.["android-cpp"].devices[0]' .github/browserstack-devices.yml) + echo "Using device: $DEVICE" + + # Execute tests + cd android-cpp/QuickStartTasksCPP/appium-test + export BROWSERSTACK_USERNAME="${{ secrets.BROWSERSTACK_USERNAME }}" + export BROWSERSTACK_ACCESS_KEY="${{ secrets.BROWSERSTACK_ACCESS_KEY }}" + export BROWSERSTACK_APP_URL="${{ steps.upload.outputs.app_url }}" + export BROWSERSTACK_DEVICE="$DEVICE" + export DITTO_CLOUD_TASK_TITLE="$TASK_TITLE" + export BROWSERSTACK_PROJECT="${{ steps.build-info.outputs.project-name }}" + export BROWSERSTACK_BUILD="${{ steps.build-info.outputs.build-name }}" + + ../gradlew test --console=plain --no-daemon + + # Query BrowserStack API to get the build ID + echo "Fetching build ID from BrowserStack..." + BUILDS_RESPONSE=$(curl -s -u "${{ secrets.BROWSERSTACK_USERNAME }}:${{ secrets.BROWSERSTACK_ACCESS_KEY }}" \ + "https://api-cloud.browserstack.com/app-automate/builds.json?limit=1") + + BUILD_ID=$(echo "$BUILDS_RESPONSE" | yq eval '.[0].hashed_id' -) + + if [ "$BUILD_ID" != "null" ] && [ -n "$BUILD_ID" ]; then + echo "build_id=$BUILD_ID" >> $GITHUB_OUTPUT + echo "โœ… Build ID: $BUILD_ID" + else + echo "โš ๏ธ Could not retrieve build ID from BrowserStack API" + fi + + - name: Upload test artifacts + if: always() + uses: actions/upload-artifact@v4 + with: + name: test-results + path: | + android-cpp/QuickStartTasksCPP/app/build/outputs/apk/ + android-cpp/QuickStartTasksCPP/appium-test/build/reports/ summary: name: CI Report runs-on: ubuntu-latest - needs: [browserstack-appium-test] + needs: [lint, build, browserstack-android] if: always() steps: - - name: Report Results - run: | - echo "## ๐Ÿ“ฑ Android C++ CI" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - - # Overall status - if [[ "${{ needs.lint.result }}" == "success" && \ - "${{ needs.build.result }}" == "success" && \ - "${{ needs.browserstack-appium-test.result }}" == "success" ]]; then - echo "**Overall Status:** โœ… All checks passed" >> $GITHUB_STEP_SUMMARY - else - echo "**Overall Status:** โŒ Failed" >> $GITHUB_STEP_SUMMARY - fi - echo "" >> $GITHUB_STEP_SUMMARY - - echo "| Job | Status |" >> $GITHUB_STEP_SUMMARY - echo "|-----|--------|" >> $GITHUB_STEP_SUMMARY - echo "| Lint | ${{ needs.lint.result == 'success' && 'โœ… Passed' || 'โŒ Failed' }} |" >> $GITHUB_STEP_SUMMARY - echo "| Build | ${{ needs.build.result == 'success' && 'โœ… Passed' || 'โŒ Failed' }} |" >> $GITHUB_STEP_SUMMARY - echo "| BrowserStack Tests | ${{ needs.browserstack-appium-test.result == 'success' && 'โœ… Passed' || (needs.browserstack-appium-test.result == 'skipped' && 'โญ๏ธ Skipped') || 'โŒ Failed' }} |" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - - # BrowserStack link - if [[ "${{ needs.browserstack-appium-test.result }}" != "skipped" ]]; then - echo "### BrowserStack Session" >> $GITHUB_STEP_SUMMARY + - name: Report Results + run: | + echo "## ๐Ÿ“ฑ Android C++ CI" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY - echo "๐Ÿค– [View Test Results](https://app-automate.browserstack.com/builds?project=Ditto+Android+C%2B%2B&build=Build+%23${{ github.run_number }})" >> $GITHUB_STEP_SUMMARY + + # Overall status + if [[ "${{ needs.lint.result }}" == "success" && \ + "${{ needs.build.result }}" == "success" && \ + "${{ needs.browserstack-android.result }}" == "success" ]]; then + echo "**Overall Status:** โœ… All checks passed" >> $GITHUB_STEP_SUMMARY + else + echo "**Overall Status:** โŒ Failed" >> $GITHUB_STEP_SUMMARY + fi + echo "" >> $GITHUB_STEP_SUMMARY + + echo "| Job | Status |" >> $GITHUB_STEP_SUMMARY + echo "|-----|--------|" >> $GITHUB_STEP_SUMMARY + echo "| Lint | ${{ needs.lint.result == 'success' && 'โœ… Passed' || 'โŒ Failed' }} |" >> $GITHUB_STEP_SUMMARY + echo "| Build | ${{ needs.build.result == 'success' && 'โœ… Passed' || 'โŒ Failed' }} |" >> $GITHUB_STEP_SUMMARY + echo "| BrowserStack Tests | ${{ needs.browserstack-android.result == 'success' && 'โœ… Passed' || (needs.browserstack-android.result == 'skipped' && 'โญ๏ธ Skipped') || 'โŒ Failed' }} |" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY - echo "**Tested Device:**" >> $GITHUB_STEP_SUMMARY - echo "- Google Pixel 7 (Android 13.0)" >> $GITHUB_STEP_SUMMARY - fi + + # BrowserStack link + if [[ "${{ needs.browserstack-android.result }}" != "skipped" ]]; then + echo "### BrowserStack Session" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + if [ -n "${{ needs.browserstack-android.outputs.build_id }}" ]; then + echo "๐Ÿค– [View Test Results](https://app-automate.browserstack.com/dashboard/v2/builds/${{ needs.browserstack-android.outputs.build_id }}/)" >> $GITHUB_STEP_SUMMARY + fi + echo "" >> $GITHUB_STEP_SUMMARY + echo "**Tested Device:**" >> $GITHUB_STEP_SUMMARY + echo "- Google Pixel 7 (Android 13.0)" >> $GITHUB_STEP_SUMMARY + fi diff --git a/.github/workflows/android-java-ci.yml b/.github/workflows/android-java-ci.yml index dc5e9c87d..ada466c02 100644 --- a/.github/workflows/android-java-ci.yml +++ b/.github/workflows/android-java-ci.yml @@ -1,16 +1,7 @@ name: Android Java CI on: - push: - branches: [ main ] - paths: - - 'android-java/**' - - '.github/workflows/android-java-ci.yml' pull_request: - branches: [ main ] - paths: - - 'android-java/**' - - '.github/workflows/android-java-ci.yml' workflow_dispatch: concurrency: @@ -22,16 +13,16 @@ jobs: name: Lint (ubuntu-latest) runs-on: ubuntu-latest timeout-minutes: 10 - + steps: - uses: actions/checkout@v4 - + - name: Setup Java uses: actions/setup-java@v4 with: distribution: 'temurin' java-version: '17' - + - name: Cache Gradle dependencies uses: actions/cache@v4 with: @@ -42,14 +33,14 @@ jobs: key: gradle-${{ runner.os }}-${{ hashFiles('android-java/gradle/wrapper/gradle-wrapper.properties', 'android-java/**/*.gradle*') }} restore-keys: | gradle-${{ runner.os }}- - + - name: Create test .env file run: | echo "DITTO_APP_ID=test" > .env echo "DITTO_PLAYGROUND_TOKEN=test" >> .env echo "DITTO_AUTH_URL=test" >> .env echo "DITTO_WEBSOCKET_URL=test" >> .env - + - name: Run Android linting working-directory: android-java run: ./gradlew lint @@ -59,22 +50,22 @@ jobs: runs-on: ubuntu-latest needs: lint timeout-minutes: 20 - + steps: - uses: actions/checkout@v4 - + - 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: Setup Gradle uses: gradle/actions/setup-gradle@v3 - + - name: Cache Gradle dependencies uses: actions/cache@v4 with: @@ -84,18 +75,18 @@ jobs: key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} restore-keys: | ${{ runner.os }}-gradle- - + - name: Create .env file run: | echo "DITTO_APP_ID=${{ secrets.DITTO_APP_ID }}" > .env echo "DITTO_PLAYGROUND_TOKEN=${{ secrets.DITTO_PLAYGROUND_TOKEN }}" >> .env echo "DITTO_AUTH_URL=${{ secrets.DITTO_AUTH_URL }}" >> .env echo "DITTO_WEBSOCKET_URL=${{ secrets.DITTO_WEBSOCKET_URL }}" >> .env - + - name: Build APKs working-directory: android-java run: ./gradlew assembleDebug assembleDebugAndroidTest - + - name: Upload APK artifacts uses: actions/upload-artifact@v4 with: @@ -105,193 +96,199 @@ jobs: android-java/app/build/outputs/apk/androidTest/debug/app-debug-androidTest.apk retention-days: 1 - browserstack-test: + browserstack-android: name: BrowserStack Device Testing runs-on: ubuntu-latest needs: [build] if: github.event_name == 'push' || github.event_name == 'pull_request' || github.event_name == 'workflow_dispatch' - timeout-minutes: 45 - + timeout-minutes: 150 + outputs: + build_id: ${{ steps.test.outputs.build_id }} + steps: - uses: actions/checkout@v4 - - name: Seed test task to Ditto Cloud - id: seed_task - uses: ./.github/actions/seed-ditto-document - with: - ditto-api-key: ${{ secrets.DITTO_API_KEY }} - ditto-api-url: ${{ secrets.DITTO_API_URL }} - app-name: 'android-java' - - name: Download APK artifacts uses: actions/download-artifact@v4 with: name: android-apks-${{ github.run_number }} path: android-java/app/build/outputs/apk/ - + - name: Upload APKs to BrowserStack id: upload run: | CREDS="${{ secrets.BROWSERSTACK_USERNAME }}:${{ secrets.BROWSERSTACK_ACCESS_KEY }}" - + # Upload app APK - echo "๐Ÿ“ฑ Uploading app APK to BrowserStack..." + echo "Uploading app APK to BrowserStack..." APP_RESPONSE=$(curl -u "$CREDS" \ -X POST "https://api-cloud.browserstack.com/app-automate/espresso/v2/app" \ -F "file=@android-java/app/build/outputs/apk/debug/app-debug.apk" \ -F "custom_id=ditto-android-java-app") - - APP_URL=$(echo "$APP_RESPONSE" | jq -r .app_url) - echo "app_url=$APP_URL" >> "$GITHUB_OUTPUT" - + + APP_URL=$(echo "$APP_RESPONSE" | yq eval -p=json .app_url) if [ "$APP_URL" = "null" ] || [ -z "$APP_URL" ]; then - echo "โŒ Failed to upload app APK" + echo "Error: Failed to upload app APK" echo "Response: $APP_RESPONSE" exit 1 fi - echo "โœ… App APK uploaded: $APP_URL" - - # Upload test APK - echo "๐Ÿงช Uploading test APK to BrowserStack..." + echo "app_url=$APP_URL" >> "$GITHUB_OUTPUT" + echo "App APK uploaded: $APP_URL" + + # Upload test APK + echo "Uploading test APK to BrowserStack..." TEST_RESPONSE=$(curl -u "$CREDS" \ -X POST "https://api-cloud.browserstack.com/app-automate/espresso/v2/test-suite" \ -F "file=@android-java/app/build/outputs/apk/androidTest/debug/app-debug-androidTest.apk" \ -F "custom_id=ditto-android-java-test") - - TEST_URL=$(echo "$TEST_RESPONSE" | jq -r .test_suite_url) - echo "test_url=$TEST_URL" >> "$GITHUB_OUTPUT" - + + TEST_URL=$(echo "$TEST_RESPONSE" | yq eval -p=json .test_suite_url) if [ "$TEST_URL" = "null" ] || [ -z "$TEST_URL" ]; then - echo "โŒ Failed to upload test APK" + echo "Error: Failed to upload test APK" echo "Response: $TEST_RESPONSE" exit 1 fi - echo "โœ… Test APK uploaded: $TEST_URL" + echo "test_url=$TEST_URL" >> "$GITHUB_OUTPUT" + echo "Test APK uploaded: $TEST_URL" - - name: Execute tests on BrowserStack + - name: Get BrowserStack build info + id: build-info + uses: ./.github/actions/generate-browserstack-names + with: + + - name: Seed and execute tests on BrowserStack id: test - run: | - # Validate inputs before creating test execution request - APP_URL="${{ steps.upload.outputs.app_url }}" - TEST_URL="${{ steps.upload.outputs.test_url }}" + 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}_android-java_ci_test_${{ github.run_id }}_${{ github.run_number }}\", + \"title\": \"${INVERTED_TIMESTAMP}_android-java_ci_test_${{ github.run_id }}_${{ github.run_number }}\", + \"done\": false, + \"deleted\": false + } + } + }" \ + "https://${{ secrets.DITTO_API_URL }}/api/v4/store/execute") - echo "App URL: $APP_URL" - echo "Test URL: $TEST_URL" + HTTP_CODE=$(echo "$SEED_RESPONSE" | tail -n1) + BODY=$(echo "$SEED_RESPONSE" | sed '$d') - if [ -z "$APP_URL" ] || [ "$APP_URL" = "null" ]; then - echo "Error: No valid app URL available" - exit 1 - fi + if [ "$HTTP_CODE" -eq 200 ] || [ "$HTTP_CODE" -eq 201 ]; then + TASK_TITLE="${INVERTED_TIMESTAMP}_android-java_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 - if [ -z "$TEST_URL" ] || [ "$TEST_URL" = "null" ]; then - echo "Error: No valid test URL available" - exit 1 - fi + # Load devices from centralized config + DEVICES=$(yq eval -o=json -I=0 '.["android-java"].devices' .github/browserstack-devices.yml) + echo "Loaded devices: $DEVICES" - # Create test execution request - TITLE="${{ steps.seed_task.outputs.document-title }}" - - BUILD_RESPONSE=$(curl -s -u '${{ secrets.BROWSERSTACK_USERNAME }}:${{ secrets.BROWSERSTACK_ACCESS_KEY }}' \ - -X POST 'https://api-cloud.browserstack.com/app-automate/espresso/v2/build' \ - -H 'Content-Type: application/json' \ - -d '{ - "app": "'"$APP_URL"'", - "testSuite": "'"$TEST_URL"'", - "devices": [ - "Google Pixel 8-14.0", - "Samsung Galaxy S23-13.0", - "Google Pixel 6-12.0", - "OnePlus 9-11.0" - ], - "project": "Ditto Android Java", - "buildName": "Build #${{ github.run_number }}", - "buildTag": "${{ github.ref_name }}", - "deviceLogs": true, - "video": true, - "networkLogs": true, - "autoGrantPermissions": true, - "instrumentationLogs": true, - "instrumentationOptions": { - "github_test_doc_id": "'"$TITLE"'" - } - }') - - BUILD_ID=$(echo "$BUILD_RESPONSE" | jq -r .build_id) - - if [ "$BUILD_ID" = "null" ] || [ -z "$BUILD_ID" ]; then - echo "Error: Failed to create BrowserStack build" - echo "Response: $BUILD_RESPONSE" - exit 1 - fi + # Create test execution request + BUILD_RESPONSE=$(curl -s -u '${{ secrets.BROWSERSTACK_USERNAME }}:${{ secrets.BROWSERSTACK_ACCESS_KEY }}' \ + -X POST 'https://api-cloud.browserstack.com/app-automate/espresso/v2/build' \ + -H 'Content-Type: application/json' \ + -d '{ + "app": "${{ steps.upload.outputs.app_url }}", + "testSuite": "${{ steps.upload.outputs.test_url }}", + "devices": '"$DEVICES"', + "project": "${{ steps.build-info.outputs.project-name }}", + "buildName": "${{ steps.build-info.outputs.build-name }}", + "buildTag": "${{ github.head_ref || github.ref_name }}", + "deviceLogs": true, + "video": true, + "networkLogs": true, + "autoGrantPermissions": true, + "instrumentationLogs": true, + "instrumentationOptions": { + "DITTO_CLOUD_TASK_TITLE": "'"$TASK_TITLE"'" + } + }') - echo "build_id=$BUILD_ID" >> $GITHUB_OUTPUT - echo "Build started with ID: $BUILD_ID" + BUILD_ID=$(echo "$BUILD_RESPONSE" | yq eval .build_id) + + if [ "$BUILD_ID" = "null" ] || [ -z "$BUILD_ID" ]; then + echo "Error: Failed to create BrowserStack build" + echo "Response: $BUILD_RESPONSE" + exit 1 + fi + + echo "build_id=$BUILD_ID" >> $GITHUB_OUTPUT + echo "Build started with ID: $BUILD_ID" + + # Wait for test execution 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/espresso/v2/builds/$BUILD_ID") + + STATUS=$(echo "$RESPONSE" | yq eval .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 - - name: Wait for BrowserStack tests to complete - run: | - BUILD_ID="${{ steps.test.outputs.build_id }}" - - if [ "$BUILD_ID" = "null" ] || [ -z "$BUILD_ID" ]; then - echo "โŒ No valid BUILD_ID available" - exit 1 - fi - - MAX_WAIT_TIME=1200 # 20 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/espresso/v2/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/espresso/v2/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" + done + + # Get final results + FINAL_RESULT=$(curl -s -u "${{ secrets.BROWSERSTACK_USERNAME }}:${{ secrets.BROWSERSTACK_ACCESS_KEY }}" \ + "https://api-cloud.browserstack.com/app-automate/espresso/v2/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" | yq eval .status) + if [ "$BUILD_STATUS" != "passed" ]; then + echo "โŒ Tests failed with status: $BUILD_STATUS" + + FAILED_DEVICES=$(echo "$FINAL_RESULT" | yq eval '.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!" fi - exit 1 else - echo "๐ŸŽ‰ All tests passed successfully!" + echo "โš ๏ธ Could not parse final results" + exit 1 fi - else - echo "โš ๏ธ Could not parse final results" - exit 1 - fi - + - name: Upload test artifacts if: always() uses: actions/upload-artifact@v4 @@ -304,7 +301,7 @@ jobs: summary: name: CI Report runs-on: ubuntu-latest - needs: [browserstack-test] + needs: [lint, build, browserstack-android] if: always() steps: @@ -316,7 +313,7 @@ jobs: # Overall status if [[ "${{ needs.lint.result }}" == "success" && \ "${{ needs.build.result }}" == "success" && \ - "${{ needs.browserstack-test.result }}" == "success" ]]; then + "${{ needs.browserstack-android.result }}" == "success" ]]; then echo "**Overall Status:** โœ… All checks passed" >> $GITHUB_STEP_SUMMARY else echo "**Overall Status:** โŒ Failed" >> $GITHUB_STEP_SUMMARY @@ -327,18 +324,18 @@ jobs: echo "|-----|--------|" >> $GITHUB_STEP_SUMMARY echo "| Lint | ${{ needs.lint.result == 'success' && 'โœ… Passed' || 'โŒ Failed' }} |" >> $GITHUB_STEP_SUMMARY echo "| Build | ${{ needs.build.result == 'success' && 'โœ… Passed' || 'โŒ Failed' }} |" >> $GITHUB_STEP_SUMMARY - echo "| BrowserStack Tests | ${{ needs.browserstack-test.result == 'success' && 'โœ… Passed' || (needs.browserstack-test.result == 'skipped' && 'โญ๏ธ Skipped') || 'โŒ Failed' }} |" >> $GITHUB_STEP_SUMMARY + echo "| BrowserStack Tests | ${{ needs.browserstack-android.result == 'success' && 'โœ… Passed' || (needs.browserstack-android.result == 'skipped' && 'โญ๏ธ Skipped') || 'โŒ Failed' }} |" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY # BrowserStack link - if [[ "${{ needs.browserstack-test.result }}" != "skipped" ]]; then + if [[ "${{ needs.browserstack-android.result }}" != "skipped" ]]; then echo "### BrowserStack Session" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY - echo "๐Ÿค– [View Test Results](https://app-automate.browserstack.com/builds?project=Ditto+Android+Java&build=Build+%23${{ github.run_number }})" >> $GITHUB_STEP_SUMMARY + echo "๐Ÿค– [View Test Results](https://app-automate.browserstack.com/dashboard/v2/builds/${{ needs.browserstack-android.outputs.build_id }}/)" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY echo "**Tested Devices:**" >> $GITHUB_STEP_SUMMARY echo "- Google Pixel 8 (Android 14)" >> $GITHUB_STEP_SUMMARY echo "- Samsung Galaxy S23 (Android 13)" >> $GITHUB_STEP_SUMMARY echo "- Google Pixel 6 (Android 12)" >> $GITHUB_STEP_SUMMARY echo "- OnePlus 9 (Android 11)" >> $GITHUB_STEP_SUMMARY - fi \ No newline at end of file + fi diff --git a/.github/workflows/android-kotlin-ci.yml b/.github/workflows/android-kotlin-ci.yml index 5a721579d..5c7234851 100644 --- a/.github/workflows/android-kotlin-ci.yml +++ b/.github/workflows/android-kotlin-ci.yml @@ -1,16 +1,10 @@ name: Android Kotlin CI on: - push: - branches: [ main ] - paths: - - 'android-kotlin/**' - - '.github/workflows/android-kotlin-ci.yml' pull_request: - branches: [ main ] paths: - - 'android-kotlin/**' - - '.github/workflows/android-kotlin-ci.yml' + - "android-kotlin/**" + - ".github/workflows/android-kotlin-ci.yml" workflow_dispatch: concurrency: @@ -22,37 +16,37 @@ jobs: name: Lint (ubuntu-latest) runs-on: ubuntu-latest timeout-minutes: 10 - + steps: - - uses: actions/checkout@v4 - - - name: Setup Java - uses: actions/setup-java@v4 - with: - distribution: 'temurin' - java-version: '17' - - - name: Cache Gradle dependencies - uses: actions/cache@v4 - with: - path: | - ~/.gradle/caches - ~/.gradle/wrapper - android-kotlin/QuickStartTasks/.gradle - key: gradle-${{ runner.os }}-${{ hashFiles('android-kotlin/QuickStartTasks/gradle/wrapper/gradle-wrapper.properties', 'android-kotlin/QuickStartTasks/**/*.gradle*', 'android-kotlin/QuickStartTasks/gradle/libs.versions.toml') }} - restore-keys: | - gradle-${{ runner.os }}- - - - name: Create test .env file - run: | - echo "DITTO_APP_ID=test" > .env - echo "DITTO_PLAYGROUND_TOKEN=test" >> .env - echo "DITTO_AUTH_URL=test" >> .env - echo "DITTO_WEBSOCKET_URL=test" >> .env - - - name: Run Android linting - working-directory: android-kotlin/QuickStartTasks - run: ./gradlew lint + - uses: actions/checkout@v4 + + - name: Setup Java + uses: actions/setup-java@v4 + with: + distribution: "temurin" + java-version: "17" + + - name: Cache Gradle dependencies + uses: actions/cache@v4 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + android-kotlin/QuickStartTasks/.gradle + key: gradle-${{ runner.os }}-${{ hashFiles('android-kotlin/QuickStartTasks/gradle/wrapper/gradle-wrapper.properties', 'android-kotlin/QuickStartTasks/**/*.gradle*', 'android-kotlin/QuickStartTasks/gradle/libs.versions.toml') }} + restore-keys: | + gradle-${{ runner.os }}- + + - name: Create test .env file + run: | + echo "DITTO_APP_ID=test" > .env + echo "DITTO_PLAYGROUND_TOKEN=test" >> .env + echo "DITTO_AUTH_URL=test" >> .env + echo "DITTO_WEBSOCKET_URL=test" >> .env + + - name: Run Android linting + working-directory: android-kotlin/QuickStartTasks + run: ./gradlew lint build: name: Build and Test @@ -61,281 +55,308 @@ jobs: timeout-minutes: 30 steps: - - uses: actions/checkout@v4 - - - 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: Setup Gradle - uses: gradle/actions/setup-gradle@v3 - - - name: Cache Gradle dependencies - uses: actions/cache@v4 - with: - path: | - ~/.gradle/caches - ~/.gradle/wrapper - key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} - restore-keys: | - ${{ runner.os }}-gradle- - - - name: Build APKs - working-directory: android-kotlin/QuickStartTasks - env: - DITTO_APP_ID: ${{ secrets.DITTO_APP_ID }} - DITTO_PLAYGROUND_TOKEN: ${{ secrets.DITTO_PLAYGROUND_TOKEN }} - DITTO_AUTH_URL: ${{ secrets.DITTO_AUTH_URL }} - DITTO_WEBSOCKET_URL: ${{ secrets.DITTO_WEBSOCKET_URL }} - run: ./gradlew assembleDebug assembleDebugAndroidTest - - - name: Run unit tests - working-directory: android-kotlin/QuickStartTasks - env: - DITTO_APP_ID: ${{ secrets.DITTO_APP_ID }} - DITTO_PLAYGROUND_TOKEN: ${{ secrets.DITTO_PLAYGROUND_TOKEN }} - DITTO_AUTH_URL: ${{ secrets.DITTO_AUTH_URL }} - DITTO_WEBSOCKET_URL: ${{ secrets.DITTO_WEBSOCKET_URL }} - run: ./gradlew test - - - name: Upload APK artifacts - uses: actions/upload-artifact@v4 - with: - name: android-apks-${{ github.run_number }} - path: | - android-kotlin/QuickStartTasks/app/build/outputs/apk/debug/app-debug.apk - android-kotlin/QuickStartTasks/app/build/outputs/apk/androidTest/debug/app-debug-androidTest.apk - retention-days: 1 - - - name: Upload test reports - if: always() - uses: actions/upload-artifact@v4 - with: - name: test-reports-${{ github.run_number }} - path: android-kotlin/QuickStartTasks/app/build/reports/ - retention-days: 1 - - browserstack-test: + - uses: actions/checkout@v4 + + - 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: Setup Gradle + uses: gradle/actions/setup-gradle@v3 + + - name: Cache Gradle dependencies + uses: actions/cache@v4 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} + restore-keys: | + ${{ runner.os }}-gradle- + + - name: Build APKs + working-directory: android-kotlin/QuickStartTasks + env: + DITTO_APP_ID: ${{ secrets.DITTO_APP_ID }} + DITTO_PLAYGROUND_TOKEN: ${{ secrets.DITTO_PLAYGROUND_TOKEN }} + DITTO_AUTH_URL: ${{ secrets.DITTO_AUTH_URL }} + DITTO_WEBSOCKET_URL: ${{ secrets.DITTO_WEBSOCKET_URL }} + run: ./gradlew assembleDebug assembleDebugAndroidTest + + - name: Run unit tests + working-directory: android-kotlin/QuickStartTasks + env: + DITTO_APP_ID: ${{ secrets.DITTO_APP_ID }} + DITTO_PLAYGROUND_TOKEN: ${{ secrets.DITTO_PLAYGROUND_TOKEN }} + DITTO_AUTH_URL: ${{ secrets.DITTO_AUTH_URL }} + DITTO_WEBSOCKET_URL: ${{ secrets.DITTO_WEBSOCKET_URL }} + run: ./gradlew test + + - name: Upload APK artifacts + uses: actions/upload-artifact@v4 + with: + name: android-apks-${{ github.run_number }} + path: | + android-kotlin/QuickStartTasks/app/build/outputs/apk/debug/app-debug.apk + android-kotlin/QuickStartTasks/app/build/outputs/apk/androidTest/debug/app-debug-androidTest.apk + retention-days: 1 + + - name: Upload test reports + if: always() + uses: actions/upload-artifact@v4 + with: + name: test-reports-${{ github.run_number }} + path: android-kotlin/QuickStartTasks/app/build/reports/ + retention-days: 1 + + browserstack-android: name: BrowserStack Device Testing runs-on: ubuntu-latest needs: [build] if: github.event_name == 'push' || github.event_name == 'pull_request' || github.event_name == 'workflow_dispatch' - timeout-minutes: 45 - + timeout-minutes: 150 + outputs: + build_id: ${{ steps.test.outputs.build_id }} + steps: - - uses: actions/checkout@v4 - - - name: Seed test task to Ditto Cloud - id: seed_task - uses: ./.github/actions/seed-ditto-document - with: - ditto-api-key: ${{ secrets.DITTO_API_KEY }} - ditto-api-url: ${{ secrets.DITTO_API_URL }} - app-name: 'android-kotlin' - - - name: Download APK artifacts - uses: actions/download-artifact@v4 - with: - name: android-apks-${{ github.run_number }} - path: android-kotlin/QuickStartTasks/app/build/outputs/apk/ - - - name: Upload APKs to BrowserStack - id: upload - run: | - CREDS="${{ secrets.BROWSERSTACK_USERNAME }}:${{ secrets.BROWSERSTACK_ACCESS_KEY }}" - - # 1. Upload AUT (app-debug.apk) - APP_UPLOAD_RESPONSE=$(curl -u "$CREDS" \ - -X POST "https://api-cloud.browserstack.com/app-automate/espresso/v2/app" \ - -F "file=@android-kotlin/QuickStartTasks/app/build/outputs/apk/debug/app-debug.apk" \ - -F "custom_id=ditto-android-kotlin-app") - APP_URL=$(echo "$APP_UPLOAD_RESPONSE" | jq -r .app_url) - echo "app_url=$APP_URL" >> "$GITHUB_OUTPUT" - - # 2. Upload Espresso test-suite (app-debug-androidTest.apk) - TEST_UPLOAD_RESPONSE=$(curl -u "$CREDS" \ - -X POST "https://api-cloud.browserstack.com/app-automate/espresso/v2/test-suite" \ - -F "file=@android-kotlin/QuickStartTasks/app/build/outputs/apk/androidTest/debug/app-debug-androidTest.apk" \ - -F "custom_id=ditto-android-kotlin-test") - TEST_URL=$(echo "$TEST_UPLOAD_RESPONSE" | jq -r .test_suite_url) - echo "test_url=$TEST_URL" >> "$GITHUB_OUTPUT" - - - name: Execute tests on BrowserStack - id: test - run: | - # Validate inputs before creating test execution request - APP_URL="${{ steps.upload.outputs.app_url }}" - TEST_URL="${{ steps.upload.outputs.test_url }}" - - echo "App URL: $APP_URL" - echo "Test URL: $TEST_URL" - - if [ -z "$APP_URL" ] || [ "$APP_URL" = "null" ]; then - echo "Error: No valid app URL available" - exit 1 - fi - - if [ -z "$TEST_URL" ] || [ "$TEST_URL" = "null" ]; then - echo "Error: No valid test URL available" - exit 1 - fi - - # Create test execution request with instrumentationOptions (correct approach for Android) - TITLE="${{ steps.seed_task.outputs.document-title }}" - - BUILD_RESPONSE=$(curl -u "${{ secrets.BROWSERSTACK_USERNAME }}:${{ secrets.BROWSERSTACK_ACCESS_KEY }}" \ - -X POST "https://api-cloud.browserstack.com/app-automate/espresso/v2/build" \ - -H "Content-Type: application/json" \ - -d "{ - \"app\": \"$APP_URL\", - \"testSuite\": \"$TEST_URL\", - \"devices\": [ - \"Google Pixel 8-14.0\", - \"Samsung Galaxy S23-13.0\", - \"Google Pixel 6-12.0\", - \"OnePlus 9-11.0\" - ], - \"project\": \"Ditto Android Kotlin\", - \"buildName\": \"Build #${{ github.run_number }}\", - \"buildTag\": \"${{ github.ref_name }}\", - \"deviceLogs\": true, - \"video\": true, - \"networkLogs\": true, - \"autoGrantPermissions\": true, - \"instrumentationOptions\": { - \"github_test_doc_id\": \"$TITLE\" - } - }") - - 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_id=$BUILD_ID" >> $GITHUB_OUTPUT - echo "Build started with ID: $BUILD_ID" - - - name: Wait for BrowserStack tests to complete - run: | - BUILD_ID="${{ steps.test.outputs.build_id }}" - - # Validate BUILD_ID before proceeding - if [ "$BUILD_ID" = "null" ] || [ -z "$BUILD_ID" ]; then - echo "Error: No valid BUILD_ID available. Skipping test monitoring." - exit 1 - fi - - MAX_WAIT_TIME=1800 # 30 minutes - CHECK_INTERVAL=30 # Check every 30 seconds - ELAPSED=0 - - while [ $ELAPSED -lt $MAX_WAIT_TIME ]; do - BUILD_STATUS_RESPONSE=$(curl -s -u "${{ secrets.BROWSERSTACK_USERNAME }}:${{ secrets.BROWSERSTACK_ACCESS_KEY }}" \ - "https://api-cloud.browserstack.com/app-automate/espresso/v2/builds/$BUILD_ID") - - BUILD_STATUS=$(echo "$BUILD_STATUS_RESPONSE" | jq -r .status) - - # Check for API errors - if [ "$BUILD_STATUS" = "null" ] || [ -z "$BUILD_STATUS" ]; then - echo "Error getting build status. Response: $BUILD_STATUS_RESPONSE" - sleep $CHECK_INTERVAL - ELAPSED=$((ELAPSED + CHECK_INTERVAL)) - continue - fi - - echo "Build status: $BUILD_STATUS (elapsed: ${ELAPSED}s)" - echo "Full response: $BUILD_STATUS_RESPONSE" - - # Check for completion states - BrowserStack uses different status values - if [ "$BUILD_STATUS" = "done" ] || [ "$BUILD_STATUS" = "failed" ] || [ "$BUILD_STATUS" = "error" ] || [ "$BUILD_STATUS" = "passed" ] || [ "$BUILD_STATUS" = "completed" ]; then - echo "Build completed with status: $BUILD_STATUS" - break + - uses: actions/checkout@v4 + + - name: Download APK artifacts + uses: actions/download-artifact@v4 + with: + name: android-apks-${{ github.run_number }} + path: android-kotlin/QuickStartTasks/app/build/outputs/apk/ + + - name: Upload APKs to BrowserStack + id: upload + run: | + CREDS="${{ secrets.BROWSERSTACK_USERNAME }}:${{ secrets.BROWSERSTACK_ACCESS_KEY }}" + + # Upload app APK + echo "Uploading app APK to BrowserStack..." + APP_RESPONSE=$(curl -u "$CREDS" \ + -X POST "https://api-cloud.browserstack.com/app-automate/espresso/v2/app" \ + -F "file=@android-kotlin/QuickStartTasks/app/build/outputs/apk/debug/app-debug.apk" \ + -F "custom_id=ditto-android-kotlin-app") + + APP_URL=$(echo "$APP_RESPONSE" | yq eval -p=json .app_url) + if [ "$APP_URL" = "null" ] || [ -z "$APP_URL" ]; then + echo "Error: Failed to upload app APK" + echo "Response: $APP_RESPONSE" + exit 1 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/espresso/v2/builds/$BUILD_ID") - - echo "Final build result:" - echo "$FINAL_RESULT" | jq . - - # Check if we got valid results - if echo "$FINAL_RESULT" | jq -e .devices > /dev/null 2>&1; then - # Check if the overall build passed - BUILD_STATUS=$(echo "$FINAL_RESULT" | jq -r .status) - if [ "$BUILD_STATUS" != "passed" ]; then - echo "Build failed with status: $BUILD_STATUS" - - # Check each device for failures - FAILED_TESTS=$(echo "$FINAL_RESULT" | jq -r '.devices[] | select(.sessions[].status != "passed") | .device') - - if [ -n "$FAILED_TESTS" ]; then - echo "Tests failed on devices: $FAILED_TESTS" - fi - + echo "app_url=$APP_URL" >> "$GITHUB_OUTPUT" + echo "App APK uploaded: $APP_URL" + + # Upload test APK + echo "Uploading test APK to BrowserStack..." + TEST_RESPONSE=$(curl -u "$CREDS" \ + -X POST "https://api-cloud.browserstack.com/app-automate/espresso/v2/test-suite" \ + -F "file=@android-kotlin/QuickStartTasks/app/build/outputs/apk/androidTest/debug/app-debug-androidTest.apk" \ + -F "custom_id=ditto-android-kotlin-test") + + TEST_URL=$(echo "$TEST_RESPONSE" | yq eval -p=json .test_suite_url) + if [ "$TEST_URL" = "null" ] || [ -z "$TEST_URL" ]; then + echo "Error: Failed to upload test APK" + echo "Response: $TEST_RESPONSE" exit 1 - else - echo "All tests passed successfully!" fi - else - echo "Warning: Could not parse final results" - echo "Raw response: $FINAL_RESULT" - fi + echo "test_url=$TEST_URL" >> "$GITHUB_OUTPUT" + echo "Test APK uploaded: $TEST_URL" + + - name: Get BrowserStack build info + id: build-info + uses: ./.github/actions/generate-browserstack-names + with: + + - name: Seed and execute tests on BrowserStack + id: test + uses: nick-fields/retry@v3 + env: + BUILD_NAME: ${{ steps.build-info.outputs.build-name }} + PROJECT_NAME: ${{ steps.build-info.outputs.project-name }} + 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}_android-kotlin_ci_test_${{ github.run_id }}_${{ github.run_number }}\", + \"title\": \"${INVERTED_TIMESTAMP}_android-kotlin_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}_android-kotlin_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 + + # Load devices from centralized config + DEVICES=$(yq eval -o=json -I=0 '.["android-kotlin"].devices' .github/browserstack-devices.yml) + + # Use jq to properly escape the build name for JSON + BUILD_NAME_JSON=$(echo "$BUILD_NAME" | jq -Rs .) + PROJECT_NAME_JSON=$(echo "$PROJECT_NAME" | jq -Rs .) + + BUILD_RESPONSE=$(curl -u "${{ secrets.BROWSERSTACK_USERNAME }}:${{ secrets.BROWSERSTACK_ACCESS_KEY }}" \ + -X POST "https://api-cloud.browserstack.com/app-automate/espresso/v2/build" \ + -H "Content-Type: application/json" \ + -d "{ + \"app\": \"${{ steps.upload.outputs.app_url }}\", + \"testSuite\": \"${{ steps.upload.outputs.test_url }}\", + \"devices\": $DEVICES, + \"project\": $PROJECT_NAME_JSON, + \"buildName\": $BUILD_NAME_JSON, + \"buildTag\": \"${{ github.head_ref || github.ref_name }}\", + \"deviceLogs\": true, + \"video\": true, + \"networkLogs\": true, + \"autoGrantPermissions\": true, + \"instrumentationOptions\": { + \"DITTO_CLOUD_TASK_TITLE\": \"$TASK_TITLE\" + } + }") + + echo "BrowserStack API Response:" + echo "$BUILD_RESPONSE" + + BUILD_ID=$(echo "$BUILD_RESPONSE" | yq eval .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" + echo "build_id=$BUILD_ID" >> "$GITHUB_OUTPUT" + + # Wait for BrowserStack tests to complete + MAX_WAIT_TIME=1080 # 18 minutes + CHECK_INTERVAL=30 # Check every 30 seconds + ELAPSED=0 + + while [ $ELAPSED -lt $MAX_WAIT_TIME ]; do + BUILD_STATUS_RESPONSE=$(curl -s -u "${{ secrets.BROWSERSTACK_USERNAME }}:${{ secrets.BROWSERSTACK_ACCESS_KEY }}" \ + "https://api-cloud.browserstack.com/app-automate/espresso/v2/builds/$BUILD_ID") + + BUILD_STATUS=$(echo "$BUILD_STATUS_RESPONSE" | yq eval .status) + + # Check for API errors + if [ "$BUILD_STATUS" = "null" ] || [ -z "$BUILD_STATUS" ]; then + echo "Error getting build status. Response: $BUILD_STATUS_RESPONSE" + sleep $CHECK_INTERVAL + ELAPSED=$((ELAPSED + CHECK_INTERVAL)) + continue + fi + + echo "Build status: $BUILD_STATUS (elapsed: ${ELAPSED}s)" + + # Check for completion states - BrowserStack uses different status values + if [ "$BUILD_STATUS" = "done" ] || [ "$BUILD_STATUS" = "failed" ] || [ "$BUILD_STATUS" = "error" ] || [ "$BUILD_STATUS" = "passed" ] || [ "$BUILD_STATUS" = "completed" ]; then + echo "Build completed with status: $BUILD_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/espresso/v2/builds/$BUILD_ID") + + echo "Final build result:" + echo "$FINAL_RESULT" | jq . + + # Check if we got valid results + if echo "$FINAL_RESULT" | jq -e .devices > /dev/null 2>&1; then + # Check if the overall build passed + BUILD_STATUS=$(echo "$FINAL_RESULT" | yq eval .status) + if [ "$BUILD_STATUS" != "passed" ]; then + echo "Build failed with status: $BUILD_STATUS" + + # Check each device for failures + FAILED_TESTS=$(echo "$FINAL_RESULT" | yq eval '.devices[] | select(.sessions[].status != "passed") | .device') + + if [ -n "$FAILED_TESTS" ]; then + echo "Tests failed on devices: $FAILED_TESTS" + fi + + exit 1 + else + echo "All tests passed successfully!" + fi + else + echo "Warning: Could not parse final results" + echo "Raw response: $FINAL_RESULT" + fi summary: name: CI Report runs-on: ubuntu-latest - needs: [browserstack-test] + needs: [lint, build, browserstack-android] if: always() steps: - - name: Report Results - run: | - echo "## ๐Ÿ“ฑ Android Kotlin CI" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - - # Overall status - if [[ "${{ needs.lint.result }}" == "success" && \ - "${{ needs.build.result }}" == "success" && \ - "${{ needs.browserstack-test.result }}" == "success" ]]; then - echo "**Overall Status:** โœ… All checks passed" >> $GITHUB_STEP_SUMMARY - else - echo "**Overall Status:** โŒ Failed" >> $GITHUB_STEP_SUMMARY - fi - echo "" >> $GITHUB_STEP_SUMMARY - - echo "| Job | Status |" >> $GITHUB_STEP_SUMMARY - echo "|-----|--------|" >> $GITHUB_STEP_SUMMARY - echo "| Lint | ${{ needs.lint.result == 'success' && 'โœ… Passed' || 'โŒ Failed' }} |" >> $GITHUB_STEP_SUMMARY - echo "| Build and Test | ${{ needs.build.result == 'success' && 'โœ… Passed' || 'โŒ Failed' }} |" >> $GITHUB_STEP_SUMMARY - echo "| BrowserStack Tests | ${{ needs.browserstack-test.result == 'success' && 'โœ… Passed' || (needs.browserstack-test.result == 'skipped' && 'โญ๏ธ Skipped') || 'โŒ Failed' }} |" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - - # BrowserStack link - if [[ "${{ needs.browserstack-test.result }}" != "skipped" ]]; then - echo "### BrowserStack Session" >> $GITHUB_STEP_SUMMARY + - name: Report Results + run: | + echo "## ๐Ÿ“ฑ Android Kotlin CI" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY - echo "๐Ÿค– [View Test Results](https://app-automate.browserstack.com/builds?project=Ditto+Android+Kotlin&build=Build+%23${{ github.run_number }})" >> $GITHUB_STEP_SUMMARY + + # Overall status + if [[ "${{ needs.lint.result }}" == "success" && \ + "${{ needs.build.result }}" == "success" && \ + "${{ needs.browserstack-android.result }}" == "success" ]]; then + echo "**Overall Status:** โœ… All checks passed" >> $GITHUB_STEP_SUMMARY + else + echo "**Overall Status:** โŒ Failed" >> $GITHUB_STEP_SUMMARY + fi echo "" >> $GITHUB_STEP_SUMMARY - echo "**Tested Devices:**" >> $GITHUB_STEP_SUMMARY - echo "- Google Pixel 8 (Android 14)" >> $GITHUB_STEP_SUMMARY - echo "- Samsung Galaxy S23 (Android 13)" >> $GITHUB_STEP_SUMMARY - echo "- Google Pixel 6 (Android 12)" >> $GITHUB_STEP_SUMMARY - echo "- OnePlus 9 (Android 11)" >> $GITHUB_STEP_SUMMARY - fi + + echo "| Job | Status |" >> $GITHUB_STEP_SUMMARY + echo "|-----|--------|" >> $GITHUB_STEP_SUMMARY + echo "| Lint | ${{ needs.lint.result == 'success' && 'โœ… Passed' || 'โŒ Failed' }} |" >> $GITHUB_STEP_SUMMARY + echo "| Build and Test | ${{ needs.build.result == 'success' && 'โœ… Passed' || 'โŒ Failed' }} |" >> $GITHUB_STEP_SUMMARY + echo "| BrowserStack Tests | ${{ needs.browserstack-android.result == 'success' && 'โœ… Passed' || (needs.browserstack-android.result == 'skipped' && 'โญ๏ธ Skipped') || 'โŒ Failed' }} |" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + + # BrowserStack link + if [[ "${{ needs.browserstack-android.result }}" != "skipped" ]]; then + echo "### BrowserStack Session" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "๐Ÿค– [View Test Results](https://app-automate.browserstack.com/dashboard/v2/builds/${{ needs.browserstack-android.outputs.build_id }}/)" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "**Tested Devices:**" >> $GITHUB_STEP_SUMMARY + echo "- Google Pixel 8 (Android 14)" >> $GITHUB_STEP_SUMMARY + echo "- Samsung Galaxy S23 (Android 13)" >> $GITHUB_STEP_SUMMARY + echo "- Google Pixel 6 (Android 12)" >> $GITHUB_STEP_SUMMARY + echo "- OnePlus 9 (Android 11)" >> $GITHUB_STEP_SUMMARY + fi diff --git a/.github/workflows/cpp-tui-ci.yml b/.github/workflows/cpp-tui-ci.yml index 866966b23..89c5f8912 100644 --- a/.github/workflows/cpp-tui-ci.yml +++ b/.github/workflows/cpp-tui-ci.yml @@ -1,11 +1,6 @@ name: C++ TUI CI on: - push: - branches: [main] - paths: - - 'cpp-tui/**' pull_request: - branches: [main] paths: - 'cpp-tui/**' workflow_dispatch: @@ -20,16 +15,16 @@ jobs: name: Lint runs-on: ubuntu-latest timeout-minutes: 10 - + steps: - name: Checkout code uses: actions/checkout@v4 - + - name: Install dependencies run: | sudo apt-get update sudo apt-get install -y clang-format - + - name: Run clang-format check working-directory: cpp-tui/taskscpp run: | @@ -44,27 +39,27 @@ jobs: runs-on: ubuntu-latest needs: lint timeout-minutes: 15 - + steps: - name: Checkout code uses: actions/checkout@v4 - + - name: Install dependencies run: | sudo apt-get update sudo apt-get install -y cmake clang build-essential - + - name: Create .env file run: | echo "DITTO_APP_ID=${{ secrets.DITTO_APP_ID }}" > .env echo "DITTO_PLAYGROUND_TOKEN=${{ secrets.DITTO_PLAYGROUND_TOKEN }}" >> .env echo "DITTO_AUTH_URL=${{ secrets.DITTO_AUTH_URL }}" >> .env echo "DITTO_WEBSOCKET_URL=${{ secrets.DITTO_WEBSOCKET_URL }}" >> .env - + - name: Download and setup Ditto C++ SDK working-directory: cpp-tui/taskscpp run: make download-sdk - + - name: Build application working-directory: cpp-tui/taskscpp run: | @@ -76,23 +71,23 @@ jobs: runs-on: ubuntu-latest needs: [lint, build] timeout-minutes: 10 - + steps: - name: Checkout code uses: actions/checkout@v4 - + - name: Install dependencies run: | sudo apt-get update sudo apt-get install -y cmake clang build-essential - + - name: Create .env file run: | echo "DITTO_APP_ID=${{ secrets.DITTO_APP_ID }}" > .env echo "DITTO_PLAYGROUND_TOKEN=${{ secrets.DITTO_PLAYGROUND_TOKEN }}" >> .env echo "DITTO_AUTH_URL=${{ secrets.DITTO_AUTH_URL }}" >> .env echo "DITTO_WEBSOCKET_URL=${{ secrets.DITTO_WEBSOCKET_URL }}" >> .env - + - name: Seed test document to Ditto Cloud id: seed uses: ./.github/actions/seed-ditto-document @@ -100,15 +95,15 @@ jobs: ditto-api-key: ${{ secrets.DITTO_API_KEY }} ditto-api-url: ${{ secrets.DITTO_API_URL }} app-name: 'cpp-tui' - + - name: Download and setup Ditto C++ SDK working-directory: cpp-tui/taskscpp run: make download-sdk - + - name: Build and run integration test working-directory: cpp-tui/taskscpp env: - GITHUB_TEST_DOC_TITLE: ${{ steps.seed.outputs.document-title }} + DITTO_CLOUD_TASK_TITLE: ${{ steps.seed.outputs.document-title }} GITHUB_RUN_ID: ${{ github.run_id }} GITHUB_RUN_NUMBER: ${{ github.run_number }} run: | diff --git a/.github/workflows/dotnet-maui-ci.yml b/.github/workflows/dotnet-maui-ci.yml index 65638a202..7a547d7a8 100644 --- a/.github/workflows/dotnet-maui-ci.yml +++ b/.github/workflows/dotnet-maui-ci.yml @@ -1,16 +1,10 @@ name: .NET MAUI CI on: - push: - branches: [main] - paths: - - 'dotnet-maui/**' - - '.github/workflows/dotnet-maui-ci.yml' pull_request: - branches: [main] paths: - - 'dotnet-maui/**' - - '.github/workflows/dotnet-maui-ci.yml' + - "dotnet-maui/**" + - ".github/workflows/dotnet-maui-ci.yml" workflow_dispatch: concurrency: @@ -30,7 +24,7 @@ jobs: - name: Setup .NET uses: actions/setup-dotnet@v4 with: - dotnet-version: '9.0.x' + dotnet-version: "9.0.x" - name: Create test .env file run: | @@ -67,7 +61,7 @@ jobs: - name: Setup .NET uses: actions/setup-dotnet@v4 with: - dotnet-version: '9.0.x' + dotnet-version: "9.0.x" - name: Setup Android SDK uses: android-actions/setup-android@v3 @@ -110,7 +104,7 @@ jobs: - name: Setup .NET uses: actions/setup-dotnet@v4 with: - dotnet-version: '9.0.x' + dotnet-version: "9.0.x" - name: Create .env file run: | @@ -153,25 +147,19 @@ jobs: name: BrowserStack Android Tests runs-on: ubuntu-latest needs: [build-android] - timeout-minutes: 30 + timeout-minutes: 150 if: github.event_name == 'push' || (github.event_name == 'pull_request' && github.event.action != 'closed') + outputs: + build_id: ${{ steps.test.outputs.build_id }} steps: - name: Checkout code uses: actions/checkout@v4 - - name: Seed test task to Ditto Cloud - id: seed_task - uses: ./.github/actions/seed-ditto-document - with: - ditto-api-key: ${{ secrets.DITTO_API_KEY }} - ditto-api-url: ${{ secrets.DITTO_API_URL }} - app-name: 'dotnet-maui' - - name: Setup .NET uses: actions/setup-dotnet@v4 with: - dotnet-version: '9.0.x' + dotnet-version: "9.0.x" - name: Download Android APK artifact uses: actions/download-artifact@v4 @@ -179,12 +167,11 @@ jobs: name: android-apk path: ./android-artifact - - name: Upload Android APK to BrowserStack - id: upload-android + - name: Upload APK to BrowserStack + id: upload run: | - echo "Uploading Android APK..." + echo "Uploading Android APK to BrowserStack..." APK_PATH="./android-artifact/live.ditto.quickstart.mauitasksapp-Signed.apk" - if [ ! -f "$APK_PATH" ]; then echo "Error: APK not found at $APK_PATH" find ./android-artifact -name "*.apk" -type f @@ -196,51 +183,109 @@ jobs: -F "file=@$APK_PATH" \ -F "custom_id=ditto-dotnet-maui-android") - echo "Upload response: $UPLOAD_RESPONSE" - APP_URL=$(echo $UPLOAD_RESPONSE | jq -r .app_url) - + APP_URL=$(echo "$UPLOAD_RESPONSE" | yq eval -p=json .app_url) if [ "$APP_URL" = "null" ] || [ -z "$APP_URL" ]; then echo "Error: Failed to upload APK" exit 1 fi - - echo "app_url=$APP_URL" >> $GITHUB_OUTPUT + echo "app_url=$APP_URL" >> "$GITHUB_OUTPUT" echo "Android APK uploaded: $APP_URL" - - name: Run Android tests on BrowserStack - working-directory: dotnet-maui/UITests.Android - run: | - dotnet restore - BROWSERSTACK_USERNAME="${{ secrets.BROWSERSTACK_USERNAME }}" \ - BROWSERSTACK_ACCESS_KEY="${{ secrets.BROWSERSTACK_ACCESS_KEY }}" \ - BROWSERSTACK_APP_ID="${{ steps.upload-android.outputs.app_url }}" \ - EXPECTED_TASK_TITLE="${{ steps.seed_task.outputs.document-title }}" \ - BUILD_NAME="CI Build #${{ github.run_number }}" \ - dotnet run + - name: Get BrowserStack build info (Android) + id: build-info-android + uses: ./.github/actions/generate-browserstack-names + with: + platform-suffix: ' (Android)' + title-max-length: '90' + commit-max-length: '130' + + - name: Seed and execute Android tests on BrowserStack + 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}_dotnet-maui_ci_test_${{ github.run_id }}_${{ github.run_number }}\", + \"title\": \"${INVERTED_TIMESTAMP}_dotnet-maui_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}_dotnet-maui_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 + + # Load device from centralized config + DEVICE=$(yq eval '.["dotnet-maui"].android.devices[0]' .github/browserstack-devices.yml) + echo "Using Android device: $DEVICE" + + # Execute tests + cd dotnet-maui/UITests.Android + dotnet restore + BROWSERSTACK_USERNAME="${{ secrets.BROWSERSTACK_USERNAME }}" \ + BROWSERSTACK_ACCESS_KEY="${{ secrets.BROWSERSTACK_ACCESS_KEY }}" \ + BROWSERSTACK_APP_ID="${{ steps.upload.outputs.app_url }}" \ + BROWSERSTACK_DEVICE="$DEVICE" \ + DITTO_CLOUD_TASK_TITLE="$TASK_TITLE" \ + BROWSERSTACK_PROJECT="${{ steps.build-info-android.outputs.project-name }}" \ + BUILD_NAME="${{ steps.build-info-android.outputs.build-name }}" \ + dotnet run + + # Query BrowserStack API to get the build ID + echo "Fetching build ID from BrowserStack..." + BUILDS_RESPONSE=$(curl -s -u "${{ secrets.BROWSERSTACK_USERNAME }}:${{ secrets.BROWSERSTACK_ACCESS_KEY }}" \ + "https://api-cloud.browserstack.com/app-automate/builds.json?limit=1") + + BUILD_ID=$(echo "$BUILDS_RESPONSE" | yq eval '.[0].hashed_id' -) + + if [ "$BUILD_ID" != "null" ] && [ -n "$BUILD_ID" ]; then + echo "build_id=$BUILD_ID" >> $GITHUB_OUTPUT + echo "โœ… Build ID: $BUILD_ID" + else + echo "โš ๏ธ Could not retrieve build ID from BrowserStack API" + fi browserstack-ios: name: BrowserStack iOS Tests runs-on: macos-latest needs: [build-ios] - timeout-minutes: 60 + timeout-minutes: 150 if: github.event_name == 'push' || (github.event_name == 'pull_request' && github.event.action != 'closed') + outputs: + build_id: ${{ steps.test.outputs.build_id }} steps: - name: Checkout code uses: actions/checkout@v4 - - name: Seed test task to Ditto Cloud - id: seed_task - uses: ./.github/actions/seed-ditto-document - with: - ditto-api-key: ${{ secrets.DITTO_API_KEY }} - ditto-api-url: ${{ secrets.DITTO_API_URL }} - app-name: 'dotnet-maui' - - name: Setup .NET uses: actions/setup-dotnet@v4 with: - dotnet-version: '9.0.x' + dotnet-version: "9.0.x" - name: Download iOS IPA artifact uses: actions/download-artifact@v4 @@ -248,12 +293,11 @@ jobs: name: ios-ipa path: ./ios-artifact - - name: Upload iOS IPA to BrowserStack - id: upload-ios + - name: Upload IPA to BrowserStack + id: upload run: | - echo "Uploading iOS IPA..." + echo "Uploading iOS IPA to BrowserStack..." IPA_PATH="./ios-artifact/DittoMauiTasksApp-device.ipa" - if [ ! -f "$IPA_PATH" ]; then echo "Error: IPA not found at $IPA_PATH" find ./ios-artifact -name "*.ipa" -type f @@ -265,32 +309,96 @@ jobs: -F "file=@$IPA_PATH" \ -F "custom_id=ditto-dotnet-maui-ios") - echo "Upload response: $UPLOAD_RESPONSE" - APP_URL=$(echo $UPLOAD_RESPONSE | jq -r .app_url) - + APP_URL=$(echo "$UPLOAD_RESPONSE" | yq eval -p=json .app_url) if [ "$APP_URL" = "null" ] || [ -z "$APP_URL" ]; then echo "Error: Failed to upload IPA" exit 1 fi - - echo "app_url=$APP_URL" >> $GITHUB_OUTPUT + echo "app_url=$APP_URL" >> "$GITHUB_OUTPUT" echo "iOS IPA uploaded: $APP_URL" - - name: Run iOS tests on BrowserStack - working-directory: dotnet-maui/UITests.iOS - run: | - dotnet restore - BROWSERSTACK_USERNAME="${{ secrets.BROWSERSTACK_USERNAME }}" \ - BROWSERSTACK_ACCESS_KEY="${{ secrets.BROWSERSTACK_ACCESS_KEY }}" \ - BROWSERSTACK_APP_ID="${{ steps.upload-ios.outputs.app_url }}" \ - EXPECTED_TASK_TITLE="${{ steps.seed_task.outputs.document-title }}" \ - BUILD_NAME="CI Build #${{ github.run_number }}" \ - dotnet run + - name: Get BrowserStack build info (iOS) + id: build-info-ios + uses: ./.github/actions/generate-browserstack-names + with: + platform-suffix: ' (iOS)' + title-max-length: '90' + commit-max-length: '130' + + - name: Seed and execute iOS tests on BrowserStack + 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}_dotnet-maui_ci_test_${{ github.run_id }}_${{ github.run_number }}\", + \"title\": \"${INVERTED_TIMESTAMP}_dotnet-maui_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}_dotnet-maui_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 + + # Load device from centralized config + DEVICE=$(yq eval '.["dotnet-maui"].ios.devices[0]' .github/browserstack-devices.yml) + echo "Using iOS device: $DEVICE" + + # Execute tests + cd dotnet-maui/UITests.iOS + dotnet restore + BROWSERSTACK_USERNAME="${{ secrets.BROWSERSTACK_USERNAME }}" \ + BROWSERSTACK_ACCESS_KEY="${{ secrets.BROWSERSTACK_ACCESS_KEY }}" \ + BROWSERSTACK_APP_ID="${{ steps.upload.outputs.app_url }}" \ + BROWSERSTACK_DEVICE="$DEVICE" \ + DITTO_CLOUD_TASK_TITLE="$TASK_TITLE" \ + BROWSERSTACK_PROJECT="${{ steps.build-info-ios.outputs.project-name }}" \ + BUILD_NAME="${{ steps.build-info-ios.outputs.build-name }}" \ + dotnet run + + # Query BrowserStack API to get the build ID + echo "Fetching build ID from BrowserStack..." + BUILDS_RESPONSE=$(curl -s -u "${{ secrets.BROWSERSTACK_USERNAME }}:${{ secrets.BROWSERSTACK_ACCESS_KEY }}" \ + "https://api-cloud.browserstack.com/app-automate/builds.json?limit=1") + + BUILD_ID=$(echo "$BUILDS_RESPONSE" | yq eval '.[0].hashed_id' -) + + if [ "$BUILD_ID" != "null" ] && [ -n "$BUILD_ID" ]; then + echo "build_id=$BUILD_ID" >> $GITHUB_OUTPUT + echo "โœ… Build ID: $BUILD_ID" + else + echo "โš ๏ธ Could not retrieve build ID from BrowserStack API" + fi summary: name: CI Report runs-on: ubuntu-latest - needs: [browserstack-android, browserstack-ios] + needs: [lint, build-android, build-ios, browserstack-android, browserstack-ios] if: always() steps: @@ -321,18 +429,17 @@ jobs: echo "" >> $GITHUB_STEP_SUMMARY # BrowserStack links - if [[ "${{ needs.browserstack-android.result }}" != "skipped" || "${{ needs.browserstack-ios.result }}" != "skipped" ]]; then - echo "### BrowserStack Sessions" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - - if [[ "${{ needs.browserstack-android.result }}" != "skipped" ]]; then - echo "๐Ÿค– [Android Test Session](https://app-automate.browserstack.com/builds?project=Ditto+.NET+MAUI&build=CI+Build+%23${{ github.run_number }}&filter_duration=1&filter_test_status=ALL&filter_test_status=done&filter_test_status=error&filter_test_status=failed&filter_test_status=passed&filter_test_status=queued&filter_test_status=running&filter_test_status=skipped&filter_test_status=timedout&filter_test_status=undefined&filter_test_status=unknown&product=app-automate&sort_test_duration=-1&sort_test_status=-1&filter_device_name=Google+Pixel+7)" >> $GITHUB_STEP_SUMMARY - fi + echo "### BrowserStack Test Results" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY - if [[ "${{ needs.browserstack-ios.result }}" != "skipped" ]]; then - echo "๐ŸŽ [iOS Test Session](https://app-automate.browserstack.com/builds?project=Ditto+.NET+MAUI&build=CI+Build+%23${{ github.run_number }}&filter_duration=1&filter_test_status=ALL&filter_test_status=done&filter_test_status=error&filter_test_status=failed&filter_test_status=passed&filter_test_status=queued&filter_test_status=running&filter_test_status=skipped&filter_test_status=timedout&filter_test_status=undefined&filter_test_status=unknown&product=app-automate&sort_test_duration=-1&sort_test_status=-1&filter_device_name=iPhone+15)" >> $GITHUB_STEP_SUMMARY - fi + if [[ "${{ needs.browserstack-android.result }}" != "skipped" && -n "${{ needs.browserstack-android.outputs.build_id }}" ]]; then + echo "**๐Ÿค– Android:**" >> $GITHUB_STEP_SUMMARY + echo "๐Ÿ”— [View Android Test Results](https://app-automate.browserstack.com/dashboard/v2/builds/${{ needs.browserstack-android.outputs.build_id }}/)" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + fi + if [[ "${{ needs.browserstack-ios.result }}" != "skipped" && -n "${{ needs.browserstack-ios.outputs.build_id }}" ]]; then + echo "**๐ŸŽ iOS:**" >> $GITHUB_STEP_SUMMARY + echo "๐Ÿ”— [View iOS Test Results](https://app-automate.browserstack.com/dashboard/v2/builds/${{ needs.browserstack-ios.outputs.build_id }}/)" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY - echo "[View All Sessions](https://app-automate.browserstack.com/builds?project=Ditto+.NET+MAUI&build=CI+Build+%23${{ github.run_number }})" >> $GITHUB_STEP_SUMMARY - fi \ No newline at end of file + fi diff --git a/.github/workflows/dotnet-tui-ci.yml b/.github/workflows/dotnet-tui-ci.yml index 0cfc9758b..eec13c6c8 100644 --- a/.github/workflows/dotnet-tui-ci.yml +++ b/.github/workflows/dotnet-tui-ci.yml @@ -1,11 +1,6 @@ name: .NET TUI CI on: - push: - branches: [main] - paths: - - 'dotnet-tui/**' pull_request: - branches: [main] paths: - 'dotnet-tui/**' workflow_dispatch: @@ -112,7 +107,7 @@ jobs: - name: Run .NET integration tests working-directory: dotnet-tui env: - TASK_TO_FIND: ${{ steps.seed.outputs.document-title }} + DITTO_CLOUD_TASK_TITLE: ${{ steps.seed.outputs.document-title }} GITHUB_RUN_ID: ${{ github.run_id }} GITHUB_RUN_NUMBER: ${{ github.run_number }} run: dotnet test DittoDotNetTasksConsole.Tests --configuration Release --logger "console;verbosity=detailed" diff --git a/.github/workflows/dotnet-winforms-ci.yml b/.github/workflows/dotnet-winforms-ci.yml index 995291a3b..3a43243ed 100644 --- a/.github/workflows/dotnet-winforms-ci.yml +++ b/.github/workflows/dotnet-winforms-ci.yml @@ -1,11 +1,6 @@ name: .NET WinForms CI on: - push: - branches: [main] - paths: - - 'dotnet-winforms/**' pull_request: - branches: [main] paths: - 'dotnet-winforms/**' workflow_dispatch: @@ -104,7 +99,7 @@ jobs: - name: Build and run integration test working-directory: dotnet-winforms/IntegrationTest env: - GITHUB_TEST_DOC_TITLE: ${{ steps.seed.outputs.document-title }} + DITTO_CLOUD_TASK_TITLE: ${{ steps.seed.outputs.document-title }} run: | dotnet restore dotnet build --configuration Release --no-restore diff --git a/.github/workflows/flutter-ci.yml b/.github/workflows/flutter-ci.yml index ee30c3faa..35823b11d 100644 --- a/.github/workflows/flutter-ci.yml +++ b/.github/workflows/flutter-ci.yml @@ -1,16 +1,10 @@ name: Flutter CI on: - push: - branches: [main] - paths: - - 'flutter_app/**' - - '.github/workflows/flutter-ci.yml' pull_request: - branches: [main] paths: - - 'flutter_app/**' - - '.github/workflows/flutter-ci.yml' + - "flutter_app/**" + - ".github/workflows/flutter-ci.yml" workflow_dispatch: concurrency: @@ -30,8 +24,8 @@ jobs: - name: Setup Flutter uses: subosito/flutter-action@v2 with: - flutter-version: '3.x' - channel: 'stable' + flutter-version: "3.x" + channel: "stable" - name: Create .env file for linting run: | @@ -70,14 +64,14 @@ jobs: - name: Setup Java uses: actions/setup-java@v4 with: - distribution: 'temurin' - java-version: '17' + distribution: "temurin" + java-version: "17" - name: Setup Flutter uses: subosito/flutter-action@v2 with: - flutter-version: '3.x' - channel: 'stable' + flutter-version: "3.x" + channel: "stable" - name: Create .env file run: | @@ -125,8 +119,8 @@ jobs: - name: Setup Flutter uses: subosito/flutter-action@v2 with: - flutter-version: '3.x' - channel: 'stable' + flutter-version: "3.x" + channel: "stable" - name: Create .env file run: | @@ -195,8 +189,8 @@ jobs: - name: Setup Flutter uses: subosito/flutter-action@v2 with: - flutter-version: '3.x' - channel: 'stable' + flutter-version: "3.x" + channel: "stable" - name: Create .env file run: | @@ -244,8 +238,8 @@ jobs: - name: Setup Flutter uses: subosito/flutter-action@v2 with: - flutter-version: '3.x' - channel: 'stable' + flutter-version: "3.x" + channel: "stable" - name: Create .env file run: | @@ -293,8 +287,8 @@ jobs: - name: Setup Flutter uses: subosito/flutter-action@v2 with: - flutter-version: '3.x' - channel: 'stable' + flutter-version: "3.x" + channel: "stable" - name: Create .env file run: | @@ -342,8 +336,8 @@ jobs: - name: Setup Flutter uses: subosito/flutter-action@v2 with: - flutter-version: '3.x' - channel: 'stable' + flutter-version: "3.x" + channel: "stable" - name: Create .env file run: | @@ -378,7 +372,7 @@ jobs: runs-on: ubuntu-latest needs: [build-android] if: github.event_name == 'push' || github.event_name == 'pull_request' || github.event_name == 'workflow_dispatch' - timeout-minutes: 45 + timeout-minutes: 150 outputs: build_id: ${{ steps.test.outputs.build_id }} @@ -386,25 +380,17 @@ jobs: - name: Checkout code uses: actions/checkout@v4 - - name: Seed test task to Ditto Cloud - id: seed_task - uses: ./.github/actions/seed-ditto-document - with: - ditto-api-key: ${{ secrets.DITTO_API_KEY }} - ditto-api-url: ${{ secrets.DITTO_API_URL }} - app-name: 'flutter' - - name: Setup Java uses: actions/setup-java@v4 with: - distribution: 'temurin' - java-version: '17' + distribution: "temurin" + java-version: "17" - name: Setup Flutter uses: subosito/flutter-action@v2 with: - flutter-version: '3.x' - channel: 'stable' + flutter-version: "3.x" + channel: "stable" - name: Create .env file run: | @@ -428,181 +414,214 @@ jobs: working-directory: flutter_app run: flutter pub get - - name: Build Android debug APKs with seeded task title - working-directory: flutter_app/android - run: | - # Use the task title from seed job - TITLE="${{ steps.seed_task.outputs.document-title }}" - ENCODED_TASK=$(echo -n "TASK_TO_FIND=$TITLE" | base64) - TARGET_PATH="$(pwd)/../integration_test/app_test.dart" - - echo "๐Ÿ“ฆ Building Android debug APKs with task title: $TITLE" - echo "๐Ÿ“ฆ Encoded: SU5URUdSQVRJT05fVEVTVF9NT0RFPXRydWU=,$ENCODED_TASK" - echo "๐Ÿ“ฆ Target: $TARGET_PATH" - - chmod +x gradlew - ./gradlew assembleDebug assembleDebugAndroidTest \ - -Ptarget="$TARGET_PATH" \ - -Pdart-defines="SU5URUdSQVRJT05fVEVTVF9NT0RFPXRydWU=,$ENCODED_TASK" - - - name: Upload app APK to BrowserStack - id: upload - run: | - 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") - - echo "App upload response: $APP_RESPONSE" - APP_URL=$(echo "$APP_RESPONSE" | jq -r .app_url) - echo "app_url=$APP_URL" >> "$GITHUB_OUTPUT" - - if [ "$APP_URL" = "null" ] || [ -z "$APP_URL" ]; then - echo "โŒ Failed to upload app APK" - echo "Response: $APP_RESPONSE" - exit 1 - fi - echo "โœ… App APK uploaded: $APP_URL" - - - name: Upload test suite APK to BrowserStack - id: upload_test - run: | - 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") - - echo "Test suite upload response: $TEST_RESPONSE" - TEST_URL=$(echo "$TEST_RESPONSE" | jq -r .test_suite_url) - echo "test_url=$TEST_URL" >> "$GITHUB_OUTPUT" - - if [ "$TEST_URL" = "null" ] || [ -z "$TEST_URL" ]; then - echo "โŒ Failed to upload test suite APK" - echo "Response: $TEST_RESPONSE" - exit 1 - fi - echo "โœ… Test suite APK uploaded: $TEST_URL" + - name: Get BrowserStack build info (Android) + id: build-info-android + uses: ./.github/actions/generate-browserstack-names + with: + platform-suffix: ' (Android)' + title-max-length: '90' + commit-max-length: '130' - - name: Execute tests on BrowserStack + - name: Seed, build, upload, and execute BrowserStack Android tests id: test - run: | - # Validate inputs before creating test execution request - APP_URL="${{ steps.upload.outputs.app_url }}" - TEST_URL="${{ steps.upload_test.outputs.test_url }}" - - echo "App URL: $APP_URL" - echo "Test URL: $TEST_URL" + 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 "โœ… Document inserted successfully with title: $TASK_TITLE" + else + echo "โŒ Failed to insert document. HTTP status: $HTTP_CODE" + echo "Response: $BODY" + exit 1 + fi - if [ -z "$APP_URL" ] || [ "$APP_URL" = "null" ]; then - echo "Error: No valid app URL available" - exit 1 - fi + # Build Android debug APKs with seeded task title + cd flutter_app/android + ENCODED_TASK=$(echo -n "TASK_TO_FIND=$TASK_TITLE" | base64) + TARGET_PATH="$(pwd)/../integration_test/app_test.dart" - if [ -z "$TEST_URL" ] || [ "$TEST_URL" = "null" ]; then - echo "Error: No valid test URL available" - exit 1 - fi + echo "๐Ÿ“ฆ Building Android debug APKs with task title: $TASK_TITLE" + echo "๐Ÿ“ฆ Encoded: $ENCODED_TASK" + echo "๐Ÿ“ฆ Target: $TARGET_PATH" - # Create test execution request - 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\": [ - \"Google Pixel 7-13.0\", - \"Samsung Galaxy S23-13.0\", - \"Google Pixel 6-12.0\" - ], - \"project\": \"Ditto Flutter\", - \"buildName\": \"Android Build #${{ github.run_number }}\", - \"buildTag\": \"${{ github.ref_name }}\", - \"deviceLogs\": true, - \"video\": true, - \"networkLogs\": 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 + chmod +x gradlew + ./gradlew assembleDebug assembleDebugAndroidTest \ + -Ptarget="$TARGET_PATH" \ + -Pdart-defines="SU5URUdSQVRJT05fVEVTVF9NT0RFPXRydWU=,$ENCODED_TASK" - echo "build_id=$BUILD_ID" >> $GITHUB_OUTPUT - echo "Build started with ID: $BUILD_ID" + cd ../.. - - name: Wait for BrowserStack tests to complete - run: | - BUILD_ID="${{ steps.test.outputs.build_id }}" + # 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") - if [ "$BUILD_ID" = "null" ] || [ -z "$BUILD_ID" ]; then - echo "โŒ No valid BUILD_ID available" - exit 1 - fi + echo "App upload response: $APP_RESPONSE" + APP_URL=$(echo "$APP_RESPONSE" | yq eval -p=json .app_url) - MAX_WAIT_TIME=1200 # 20 minutes - CHECK_INTERVAL=30 # Check every 30 seconds - ELAPSED=0 + if [ "$APP_URL" = "null" ] || [ -z "$APP_URL" ]; then + echo "โŒ Failed to upload app APK" + echo "Response: $APP_RESPONSE" + exit 1 + fi + echo "โœ… App APK uploaded: $APP_URL" - 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") + # 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") - STATUS=$(echo "$RESPONSE" | jq -r .status) + echo "Test suite upload response: $TEST_RESPONSE" + TEST_URL=$(echo "$TEST_RESPONSE" | yq eval -p=json .test_suite_url) - if [ "$STATUS" = "null" ] || [ -z "$STATUS" ]; then - echo "โš ๏ธ API error, retrying... (${ELAPSED}s elapsed)" - sleep $CHECK_INTERVAL - ELAPSED=$((ELAPSED + CHECK_INTERVAL)) - continue + if [ "$TEST_URL" = "null" ] || [ -z "$TEST_URL" ]; then + echo "โŒ Failed to upload test suite APK" + echo "Response: $TEST_RESPONSE" + exit 1 + fi + echo "โœ… Test suite APK uploaded: $TEST_URL" + + # Execute tests on BrowserStack + echo "๐Ÿš€ Executing tests on BrowserStack..." + + # Load devices from centralized config + DEVICES=$(yq eval -o=json -I=0 '.flutter.android.devices' .github/browserstack-devices.yml) + + # Create test execution request + 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\": \"${{ steps.build-info-android.outputs.project-name }}\", + \"buildName\": \"${{ steps.build-info-android.outputs.build-name }}\", + \"buildTag\": \"${{ github.head_ref || github.ref_name }}\", + \"deviceLogs\": true, + \"video\": true, + \"networkLogs\": true, + \"autoGrantPermissions\": true + }") + + echo "BrowserStack API Response:" + echo "$BUILD_RESPONSE" + + BUILD_ID=$(echo "$BUILD_RESPONSE" | yq eval .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 "๐Ÿ“Š Status: $STATUS (${ELAPSED}s elapsed)" + echo "build_id=$BUILD_ID" >> $GITHUB_OUTPUT + echo "Build started with ID: $BUILD_ID" - # Check for completion - if [[ "$STATUS" =~ ^(done|failed|error|passed|completed)$ ]]; then - echo "โœ… Build completed with status: $STATUS" - break - fi + # Wait for 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") - sleep $CHECK_INTERVAL - ELAPSED=$((ELAPSED + CHECK_INTERVAL)) - done + STATUS=$(echo "$RESPONSE" | yq eval .status) - # 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") + if [ "$STATUS" = "null" ] || [ -z "$STATUS" ]; then + echo "โš ๏ธ API error, retrying... (${ELAPSED}s elapsed)" + sleep $CHECK_INTERVAL + ELAPSED=$((ELAPSED + CHECK_INTERVAL)) + continue + fi - echo "๐Ÿ“‹ Final results:" - echo "$FINAL_RESULT" | jq . + # Check device statuses - if all devices are done, we're done + DEVICE_STATUSES=$(echo "$RESPONSE" | yq eval '.devices[]?.sessions[]?.status' 2>/dev/null) + if [ -n "$DEVICE_STATUSES" ]; then + ALL_DONE=true + while IFS= read -r dev_status; do + if [[ ! "$dev_status" =~ ^(passed|failed|error|done)$ ]]; then + ALL_DONE=false + break + fi + done <<< "$DEVICE_STATUSES" + + if [ "$ALL_DONE" = true ]; then + echo "โœ… All devices completed (${ELAPSED}s elapsed)" + break + fi + fi - # 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" + echo "๐Ÿ“Š Status: $STATUS (${ELAPSED}s elapsed)" - FAILED_DEVICES=$(echo "$FINAL_RESULT" | jq -r '.devices[] | select(.sessions[].status != "passed") | .device') - if [ -n "$FAILED_DEVICES" ]; then - echo "Failed on devices: $FAILED_DEVICES" + # 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" | yq eval .status) + if [ "$BUILD_STATUS" != "passed" ]; then + echo "โŒ Tests failed with status: $BUILD_STATUS" + + FAILED_DEVICES=$(echo "$FINAL_RESULT" | yq eval '.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 - exit 1 else - echo "๐ŸŽ‰ All tests passed successfully!" - echo "Dashboard URL: https://app-automate.browserstack.com/dashboard/v2/builds/$BUILD_ID" + echo "โš ๏ธ Could not parse final results" + exit 1 fi - else - echo "โš ๏ธ Could not parse final results" - exit 1 - fi - name: Add to GitHub Actions Summary if: always() @@ -617,9 +636,10 @@ jobs: 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 + if [ -n "$BUILD_ID" ]; then + 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 + fi echo "" >> $GITHUB_STEP_SUMMARY browserstack-ios: @@ -627,31 +647,17 @@ jobs: runs-on: macos-latest needs: [build-ios] if: github.event_name == 'push' || github.event_name == 'pull_request' || github.event_name == 'workflow_dispatch' - timeout-minutes: 45 + timeout-minutes: 150 steps: - name: Checkout code uses: actions/checkout@v4 - - name: Seed test task to Ditto Cloud - id: seed_task - uses: ./.github/actions/seed-ditto-document - with: - ditto-api-key: ${{ secrets.DITTO_API_KEY }} - ditto-api-url: ${{ secrets.DITTO_API_URL }} - app-name: 'flutter' - - - name: Download iOS IPA - uses: actions/download-artifact@v4 - with: - name: flutter-ios-ipa-${{ github.run_number }} - path: ios-artifacts - - name: Setup Flutter uses: subosito/flutter-action@v2 with: - flutter-version: '3.x' - channel: 'stable' + flutter-version: "3.x" + channel: "stable" - name: Create .env file run: | @@ -668,186 +674,221 @@ jobs: working-directory: flutter_app/ios run: pod install || pod install --repo-update - - name: Build iOS integration test package with seeded task title - working-directory: flutter_app - run: | - # Use the task title from seed job - TITLE="${{ steps.seed_task.outputs.document-title }}" - echo "๐Ÿงช Building iOS integration test package with task title: $TITLE" - - flutter build ios integration_test/app_test.dart --release --no-codesign \ - --dart-define=INTEGRATION_TEST_MODE=true \ - --dart-define="TASK_TO_FIND=$TITLE" - - - name: Create iOS test package - run: | - output="../build/ios_integration" - product="build/ios_integration/Build/Products" - - pushd flutter_app/ios - xcodebuild -workspace Runner.xcworkspace \ - -scheme Runner \ - -config Flutter/Release.xcconfig \ - -derivedDataPath $output \ - -sdk iphoneos \ - build-for-testing \ - CODE_SIGNING_ALLOWED=NO - popd - - pushd flutter_app/$product - 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 + - name: Download iOS IPA + uses: actions/download-artifact@v4 + with: + name: flutter-ios-ipa-${{ github.run_number }} + path: flutter_app/build/ios/ipa/ - - name: Upload app IPA to BrowserStack - id: upload-app - run: | - echo "๐Ÿ“ค Uploading iOS app to BrowserStack..." - APP_RESPONSE=$(curl -u "${{ secrets.BROWSERSTACK_USERNAME }}:${{ secrets.BROWSERSTACK_ACCESS_KEY }}" \ - -X POST "https://api-cloud.browserstack.com/app-automate/upload" \ - -F "file=@ios-artifacts/Runner-unsigned.ipa") - - echo "App upload response: $APP_RESPONSE" - APP_URL=$(echo "$APP_RESPONSE" | jq -r .app_url) - echo "app_url=$APP_URL" >> "$GITHUB_OUTPUT" - echo "โœ… iOS app uploaded: $APP_URL" - - - name: Upload test package to BrowserStack - id: upload - run: | - echo "๐Ÿ“ค Uploading iOS test package to BrowserStack..." - 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: $RESPONSE" - TEST_PACKAGE_URL=$(echo "$RESPONSE" | jq -r .test_package_url) - echo "test_package_url=$TEST_PACKAGE_URL" >> "$GITHUB_OUTPUT" - - if [ "$TEST_PACKAGE_URL" = "null" ] || [ -z "$TEST_PACKAGE_URL" ]; then - echo "โŒ Failed to upload iOS test package" - echo "Response: $RESPONSE" - exit 1 - fi - echo "โœ… iOS test package uploaded: $TEST_PACKAGE_URL" + - name: Get BrowserStack build info (iOS) + id: build-info-ios + uses: ./.github/actions/generate-browserstack-names + with: + platform-suffix: ' (iOS)' + title-max-length: '90' + commit-max-length: '130' - - name: Execute tests on BrowserStack + - name: Seed, build, upload, and execute BrowserStack iOS tests id: test - run: | - TEST_PACKAGE_URL="${{ steps.upload.outputs.test_package_url }}" - - echo "Test Package URL: $TEST_PACKAGE_URL" + 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 "โœ… Document inserted successfully with title: $TASK_TITLE" + else + echo "โŒ Failed to insert document. HTTP status: $HTTP_CODE" + echo "Response: $BODY" + exit 1 + fi - if [ -z "$TEST_PACKAGE_URL" ] || [ "$TEST_PACKAGE_URL" = "null" ]; then - echo "Error: No valid test package URL available" - exit 1 - fi + # Build iOS integration test package with seeded task title + cd flutter_app + echo "๐Ÿงช Building iOS integration test package with task title: $TASK_TITLE" + + flutter build ios integration_test/app_test.dart --release --no-codesign \ + --dart-define=INTEGRATION_TEST_MODE=true \ + --dart-define="TASK_TO_FIND=$TASK_TITLE" + + # Create iOS test package + cd .. + output="../build/ios_integration" + product="build/ios_integration/Build/Products" + + pushd flutter_app/ios + xcodebuild -workspace Runner.xcworkspace \ + -scheme Runner \ + -config Flutter/Release.xcconfig \ + -derivedDataPath $output \ + -sdk iphoneos \ + build-for-testing \ + CODE_SIGNING_ALLOWED=NO + popd + + pushd flutter_app/$product + 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" | yq eval -p=json .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 tests on BrowserStack + echo "๐Ÿš€ Executing tests on BrowserStack..." + + # 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=$(yq eval -o=json -I=0 '.flutter.ios.devices' .github/browserstack-devices.yml) + + 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\": \"${{ steps.build-info-ios.outputs.project-name }}\", + \"buildName\": \"${{ steps.build-info-ios.outputs.build-name }}\", + \"buildTag\": \"${{ github.head_ref || github.ref_name }}\", + \"deviceLogs\": true, + \"networkLogs\": true, + \"autoGrantPermissions\": true + }") + + echo "BrowserStack API Response:" + echo "$BUILD_RESPONSE" + + BUILD_ID=$(echo "$BUILD_RESPONSE" | yq eval .build_id) + echo "build_id=$BUILD_ID" >> "$GITHUB_OUTPUT" + + # 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 - # 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 - 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\": [ - \"iPhone 13-15\", - \"iPhone 14-16\", - \"iPhone 12-17\" - ], - \"project\": \"Ditto Flutter\", - \"buildName\": \"iOS Build #${{ github.run_number }}\", - \"buildTag\": \"${{ github.ref_name }}\", - \"deviceLogs\": true, - \"networkLogs\": 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" - echo "build_id=$BUILD_ID" >> $GITHUB_OUTPUT - echo "Build started with ID: $BUILD_ID" + # Wait for tests to complete + MAX_WAIT_TIME=1080 # 18 minutes + CHECK_INTERVAL=30 # Check every 30 seconds + ELAPSED=0 - - name: Wait for BrowserStack tests to complete - run: | - BUILD_ID="${{ steps.test.outputs.build_id }}" + 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") - if [ "$BUILD_ID" = "null" ] || [ -z "$BUILD_ID" ]; then - echo "โŒ No valid BUILD_ID available" - exit 1 - fi + STATUS=$(echo "$RESPONSE" | yq eval .status) - MAX_WAIT_TIME=1200 # 20 minutes - CHECK_INTERVAL=30 # Check every 30 seconds - ELAPSED=0 + if [ "$STATUS" = "null" ] || [ -z "$STATUS" ]; then + echo "โš ๏ธ API error, retrying... (${ELAPSED}s elapsed)" + sleep $CHECK_INTERVAL + ELAPSED=$((ELAPSED + CHECK_INTERVAL)) + continue + fi - 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") + echo "๐Ÿ“Š Build Status: $STATUS (${ELAPSED}s elapsed)" + + # Check device statuses - if all devices are done, we're done + DEVICE_STATUSES=$(echo "$RESPONSE" | yq eval '.devices[]?.sessions[]?.status' 2>/dev/null) + if [ -n "$DEVICE_STATUSES" ]; then + ALL_DONE=true + while IFS= read -r dev_status; do + if [[ ! "$dev_status" =~ ^(passed|failed|error|done)$ ]]; then + ALL_DONE=false + break + fi + done <<< "$DEVICE_STATUSES" + + if [ "$ALL_DONE" = true ]; then + echo "โœ… All devices completed (${ELAPSED}s elapsed)" + break + fi + fi - STATUS=$(echo "$RESPONSE" | jq -r .status) + # Check for build-level completion as fallback + if [[ "$STATUS" =~ ^(done|failed|error|passed|completed)$ ]]; then + echo "โœ… Build completed with status: $STATUS" + break + fi - 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 . + done - # 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" + # 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") - FAILED_DEVICES=$(echo "$FINAL_RESULT" | jq -r '.devices[] | select(.sessions[].status != "passed") | .device') - if [ -n "$FAILED_DEVICES" ]; then - echo "Failed on devices: $FAILED_DEVICES" + 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" | yq eval .status) + if [ "$BUILD_STATUS" != "passed" ]; then + echo "โŒ Tests failed with status: $BUILD_STATUS" + + FAILED_DEVICES=$(echo "$FINAL_RESULT" | yq eval '.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 - exit 1 else - echo "๐ŸŽ‰ All tests passed successfully!" - echo "Dashboard URL: https://app-automate.browserstack.com/dashboard/v2/builds/$BUILD_ID" + echo "โš ๏ธ Could not parse final results" + exit 1 fi - else - echo "โš ๏ธ Could not parse final results" - exit 1 - fi - name: Add to GitHub Actions Summary if: always() @@ -862,9 +903,10 @@ jobs: 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 + if [ -n "$BUILD_ID" ]; then + 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 + fi echo "" >> $GITHUB_STEP_SUMMARY echo "---" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY @@ -872,7 +914,18 @@ jobs: summary: name: Test Summary runs-on: ubuntu-latest - needs: [browserstack-android, browserstack-ios] + 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 @@ -892,4 +945,4 @@ jobs: 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 \ No newline at end of file + echo "- **iOS:** ${{ needs.browserstack-ios.result }}" >> $GITHUB_STEP_SUMMARY diff --git a/.github/workflows/java-spring-ci.yml b/.github/workflows/java-spring-ci.yml index 5398a3030..57d55f959 100644 --- a/.github/workflows/java-spring-ci.yml +++ b/.github/workflows/java-spring-ci.yml @@ -6,22 +6,14 @@ name: Java Spring CI on: - push: - branches: [ main ] - paths: - - 'java-spring/**' - - '.github/workflows/java-spring-ci.yml' pull_request: - branches: [ main ] - paths: - - 'java-spring/**' - - '.github/workflows/java-spring-ci.yml' workflow_dispatch: concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true + jobs: lint: name: Lint (ubuntu-latest) @@ -84,7 +76,7 @@ jobs: DITTO_AUTH_URL: ${{ secrets.DITTO_AUTH_URL }} DITTO_WEBSOCKET_URL: ${{ secrets.DITTO_WEBSOCKET_URL }} DITTO_ENABLE_CLOUD_SYNC: true - run: ./gradlew bootJar + run: ./gradlew bootJar -x test - name: Upload JAR artifacts @@ -99,19 +91,13 @@ jobs: runs-on: macos-latest needs: [build] if: github.event_name == 'push' || github.event_name == 'pull_request' || github.event_name == 'workflow_dispatch' - timeout-minutes: 45 - + timeout-minutes: 150 + outputs: + build_id: ${{ steps.test.outputs.build_id }} + steps: - uses: actions/checkout@v4 - - name: Seed test task to Ditto Cloud - id: seed_task - uses: ./.github/actions/seed-ditto-document - with: - ditto-api-key: ${{ secrets.DITTO_API_KEY }} - ditto-api-url: ${{ secrets.DITTO_API_URL }} - app-name: 'java-spring' - - name: Download JAR artifacts uses: actions/download-artifact@v4 with: @@ -123,33 +109,33 @@ jobs: with: distribution: 'temurin' java-version: '17' - + - name: Setup Gradle uses: gradle/actions/setup-gradle@v3 - + - name: Create .env file (root) run: | echo "DITTO_APP_ID=${{ secrets.DITTO_APP_ID }}" > .env echo "DITTO_PLAYGROUND_TOKEN=${{ secrets.DITTO_PLAYGROUND_TOKEN }}" >> .env echo "DITTO_AUTH_URL=${{ secrets.DITTO_AUTH_URL }}" >> .env echo "DITTO_WEBSOCKET_URL=${{ secrets.DITTO_WEBSOCKET_URL }}" >> .env - + - name: Install BrowserStack Local binary run: | curl -O "https://www.browserstack.com/browserstack-local/BrowserStackLocal-darwin-x64.zip" unzip BrowserStackLocal-darwin-x64.zip chmod +x BrowserStackLocal - - name: Start BrowserStack Local tunnel + - name: Start BrowserStack Local tunnel run: | echo "Starting BrowserStack Local tunnel..." nohup ./BrowserStackLocal --key "${{ secrets.BROWSERSTACK_ACCESS_KEY }}" \ --daemon-mode > browserstack-local.log 2>&1 & - + # Wait for tunnel to establish TIMEOUT=60 ELAPSED=0 - + while [ $ELAPSED -lt $TIMEOUT ]; do if [ -f browserstack-local.log ] && grep -q "You can now access your local server(s) in our remote browser" browserstack-local.log; then echo "BrowserStack Local tunnel established" @@ -163,30 +149,35 @@ jobs: sleep 5 ELAPSED=$((ELAPSED + 5)) done - + echo "BrowserStack Local tunnel timeout after ${TIMEOUT}s" [ -f browserstack-local.log ] && cat browserstack-local.log exit 1 - + + - name: Get BrowserStack build info + id: build-info + uses: ./.github/actions/generate-browserstack-names + with: + - name: Create BrowserStack config working-directory: java-spring run: | + # Load platforms from centralized config and convert to YAML format + PLATFORMS=$(yq eval '.["java-spring"].platforms[] | " - os: \(.os)\n osVersion: \"\(.osVersion)\"\n browserName: \(.browserName)\n browserVersion: \(.browserVersion)"' ../.github/browserstack-devices.yml) + cat > browserstack.yml << EOF userName: ${{ secrets.BROWSERSTACK_USERNAME }} accessKey: ${{ secrets.BROWSERSTACK_ACCESS_KEY }} - projectName: Ditto Java Spring Tasks - buildName: Java Spring Selenium Tests #${{ github.run_number }} + projectName: ${{ steps.build-info.outputs.project-name }} + buildName: ${{ steps.build-info.outputs.build-name }} platforms: - - os: Windows - osVersion: 11 - browserName: Chrome - browserVersion: latest + $PLATFORMS browserstackLocal: true debug: true video: true parallelsPerPlatform: 1 EOF - + - name: Start Spring Boot app in background working-directory: java-spring env: @@ -210,28 +201,77 @@ jobs: fi sleep 2 done - - - name: Execute Selenium tests on BrowserStack cloud browsers - working-directory: java-spring + + - name: Seed and execute Selenium tests on BrowserStack cloud browsers + id: test + uses: nick-fields/retry@v3 env: BROWSERSTACK_USERNAME: ${{ secrets.BROWSERSTACK_USERNAME }} BROWSERSTACK_ACCESS_KEY: ${{ secrets.BROWSERSTACK_ACCESS_KEY }} BROWSERSTACK_LOCAL: true - TEST_TASK_TITLE: ${{ steps.seed_task.outputs.document-title }} - GITHUB_TEST_DOC_ID: ${{ steps.seed_task.outputs.document-title }} - run: | - TITLE="${{ steps.seed_task.outputs.document-title }}" - - # Run only the BrowserStack test method, not all test methods - ./gradlew test --tests "*TaskVisibilityIntegrationTest.shouldPassWithExistingTask" \ - -DBROWSERSTACK_USERNAME="${{ secrets.BROWSERSTACK_USERNAME }}" \ - -DBROWSERSTACK_ACCESS_KEY="${{ secrets.BROWSERSTACK_ACCESS_KEY }}" \ - -DBROWSERSTACK_BUILD_NAME="Java Spring Selenium Tests #${{ github.run_number }}" \ - -DBROWSERSTACK_LOCAL=true \ - -DTEST_TASK_TITLE="$TITLE" \ - -DGITHUB_TEST_DOC_ID="$TITLE" \ - --info - + 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}_java-spring_ci_test_${{ github.run_id }}_${{ github.run_number }}\", + \"title\": \"${INVERTED_TIMESTAMP}_java-spring_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}_java-spring_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 + + # Export as environment variable (for System.getenv()) + export DITTO_CLOUD_TASK_TITLE="$TASK_TITLE" + + # Run BrowserStack Selenium tests + cd java-spring + ./gradlew test --tests "*TaskVisibilityIntegrationTest.shouldPassWithExistingTask" \ + -DBROWSERSTACK_USERNAME="${{ secrets.BROWSERSTACK_USERNAME }}" \ + -DBROWSERSTACK_ACCESS_KEY="${{ secrets.BROWSERSTACK_ACCESS_KEY }}" \ + -DBROWSERSTACK_LOCAL=true \ + -DDITTO_CLOUD_TASK_TITLE="$TASK_TITLE" \ + --info + + # Query BrowserStack API to get the build ID + echo "Fetching build ID from BrowserStack..." + BUILDS_RESPONSE=$(curl -s -u "${{ secrets.BROWSERSTACK_USERNAME }}:${{ secrets.BROWSERSTACK_ACCESS_KEY }}" \ + "https://api.browserstack.com/automate/builds.json?limit=1") + + BUILD_ID=$(echo "$BUILDS_RESPONSE" | yq eval '.[0].hashed_id' -) + + if [ "$BUILD_ID" != "null" ] && [ -n "$BUILD_ID" ]; then + echo "build_id=$BUILD_ID" >> $GITHUB_OUTPUT + echo "โœ… Build ID: $BUILD_ID" + else + echo "โš ๏ธ Could not retrieve build ID from BrowserStack API" + fi + - name: Stop Spring Boot app if: always() working-directory: java-spring @@ -240,11 +280,11 @@ jobs: kill $(cat app.pid) || true rm app.pid fi - + - name: Stop BrowserStack Local tunnel if: always() run: pkill -f "BrowserStackLocal" || true - + - name: Upload test reports if: always() uses: actions/upload-artifact@v4 @@ -252,7 +292,7 @@ jobs: name: browserstack-test-reports-${{ github.run_number }} path: java-spring/build/reports/tests/ retention-days: 1 - + - name: Upload app logs if: always() uses: actions/upload-artifact@v4 @@ -266,7 +306,7 @@ jobs: summary: name: CI Report runs-on: ubuntu-latest - needs: [browserstack-test] + needs: [lint, build, browserstack-test] if: always() steps: @@ -293,10 +333,10 @@ jobs: echo "" >> $GITHUB_STEP_SUMMARY # BrowserStack link - if [[ "${{ needs.browserstack-test.result }}" != "skipped" ]]; then + if [[ "${{ needs.browserstack-test.result }}" != "skipped" ]] && [ -n "${{ needs.browserstack-test.outputs.build_id }}" ]; then echo "### BrowserStack Session" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY - echo "๐Ÿ”— [View Test Results](https://automate.browserstack.com/builds?project=Ditto+Java+Spring+Tasks&build=Java+Spring+Selenium+Tests+%23${{ github.run_number }})" >> $GITHUB_STEP_SUMMARY + echo "๐Ÿ”— [View Test Results](https://automate.browserstack.com/dashboard/v2/builds/${{ needs.browserstack-test.outputs.build_id }}/)" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY echo "**Tested Browser:**" >> $GITHUB_STEP_SUMMARY echo "- Chrome Latest (Windows 11)" >> $GITHUB_STEP_SUMMARY diff --git a/.github/workflows/javascript-tui-ci.yml b/.github/workflows/javascript-tui-ci.yml index 2074359cd..70f6b19be 100644 --- a/.github/workflows/javascript-tui-ci.yml +++ b/.github/workflows/javascript-tui-ci.yml @@ -1,11 +1,6 @@ name: JavaScript TUI CI on: - push: - branches: [main] - paths: - - 'javascript-tui/**' pull_request: - branches: [main] paths: - 'javascript-tui/**' workflow_dispatch: @@ -20,22 +15,22 @@ jobs: name: Lint runs-on: ubuntu-latest timeout-minutes: 10 - + steps: - name: Checkout code uses: actions/checkout@v4 - + - name: Setup Node.js uses: actions/setup-node@v4 with: node-version: '22' cache: 'npm' cache-dependency-path: javascript-tui/package-lock.json - + - name: Install dependencies working-directory: javascript-tui run: npm ci - + - name: Run lint check working-directory: javascript-tui run: npm run test:format @@ -45,29 +40,29 @@ jobs: runs-on: ubuntu-latest needs: lint timeout-minutes: 15 - + steps: - name: Checkout code uses: actions/checkout@v4 - + - name: Setup Node.js uses: actions/setup-node@v4 with: node-version: '22' cache: 'npm' cache-dependency-path: javascript-tui/package-lock.json - + - name: Install dependencies working-directory: javascript-tui run: npm ci - + - name: Create .env file run: | echo "DITTO_APP_ID=${{ secrets.DITTO_APP_ID }}" > .env echo "DITTO_PLAYGROUND_TOKEN=${{ secrets.DITTO_PLAYGROUND_TOKEN }}" >> .env echo "DITTO_AUTH_URL=${{ secrets.DITTO_AUTH_URL }}" >> .env echo "DITTO_WEBSOCKET_URL=${{ secrets.DITTO_WEBSOCKET_URL }}" >> .env - + - name: Build application working-directory: javascript-tui run: npm run build @@ -77,29 +72,29 @@ jobs: runs-on: ubuntu-latest needs: [lint, build] timeout-minutes: 10 - + steps: - name: Checkout code uses: actions/checkout@v4 - + - name: Setup Node.js uses: actions/setup-node@v4 with: node-version: '22' cache: 'npm' cache-dependency-path: javascript-tui/package-lock.json - + - name: Install dependencies working-directory: javascript-tui run: npm ci - + - name: Create .env file run: | echo "DITTO_APP_ID=${{ secrets.DITTO_APP_ID }}" > .env echo "DITTO_PLAYGROUND_TOKEN=${{ secrets.DITTO_PLAYGROUND_TOKEN }}" >> .env echo "DITTO_AUTH_URL=${{ secrets.DITTO_AUTH_URL }}" >> .env echo "DITTO_WEBSOCKET_URL=${{ secrets.DITTO_WEBSOCKET_URL }}" >> .env - + - name: Seed test document to Ditto Cloud id: seed uses: ./.github/actions/seed-ditto-document @@ -107,11 +102,11 @@ jobs: ditto-api-key: ${{ secrets.DITTO_API_KEY }} ditto-api-url: ${{ secrets.DITTO_API_URL }} app-name: 'javascript-tui' - + - name: Run integration test working-directory: javascript-tui env: - GITHUB_TEST_DOC_TITLE: ${{ steps.seed.outputs.document-title }} + DITTO_CLOUD_TASK_TITLE: ${{ steps.seed.outputs.document-title }} GITHUB_RUN_ID: ${{ github.run_id }} GITHUB_RUN_NUMBER: ${{ github.run_number }} run: npm run build && node tests/integration_test.js diff --git a/.github/workflows/javascript-web-ci.yml b/.github/workflows/javascript-web-ci.yml index 649096226..68ab1009a 100644 --- a/.github/workflows/javascript-web-ci.yml +++ b/.github/workflows/javascript-web-ci.yml @@ -1,20 +1,10 @@ -name: javascript-web-browserstack +name: JavaScript Web CI on: pull_request: - branches: - - main - - 'sdk-*' paths: - - 'javascript-web/**' - - '.github/workflows/javascript-web-browserstack.yml' - push: - branches: - - main - - 'sdk-*' - paths: - - 'javascript-web/**' - - '.github/workflows/javascript-web-browserstack.yml' + - "javascript-web/**" + - ".github/workflows/javascript-web-ci.yml" workflow_dispatch: concurrency: @@ -22,10 +12,36 @@ concurrency: cancel-in-progress: true jobs: - build-and-test: - name: Build and Test on BrowserStack + lint: + name: Lint + runs-on: ubuntu-latest + timeout-minutes: 10 + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: "22" + cache: "npm" + cache-dependency-path: javascript-web/package-lock.json + + - name: Install dependencies + working-directory: javascript-web + run: npm ci + + - name: Run linter + working-directory: javascript-web + run: npm run lint + + build: + name: Build runs-on: ubuntu-latest - + needs: lint + timeout-minutes: 10 + steps: - name: Checkout code uses: actions/checkout@v4 @@ -33,8 +49,8 @@ jobs: - name: Set up Node.js uses: actions/setup-node@v4 with: - node-version: '22' - cache: 'npm' + node-version: "22" + cache: "npm" cache-dependency-path: javascript-web/package-lock.json - name: Create .env file @@ -44,36 +60,51 @@ jobs: echo "DITTO_AUTH_URL=${{ secrets.DITTO_AUTH_URL }}" >> .env echo "DITTO_WEBSOCKET_URL=${{ secrets.DITTO_WEBSOCKET_URL }}" >> .env - - name: Insert test document into Ditto Cloud - id: seed_document - uses: ./.github/actions/seed-ditto-document - with: - ditto-api-key: ${{ secrets.DITTO_API_KEY }} - ditto-api-url: ${{ secrets.DITTO_API_URL }} - app-name: 'javascript-web' - - - name: Set document ID for tests - run: | - echo "GITHUB_TEST_DOC_ID=${{ steps.seed_document.outputs.document-title }}" >> $GITHUB_ENV - - name: Install dependencies working-directory: javascript-web run: npm ci - - name: Run linter - working-directory: javascript-web - run: npm run lint - - name: Build application working-directory: javascript-web - run: | - npm run build - echo "App built successfully" + run: npm run build + + - name: Upload build artifact + uses: actions/upload-artifact@v4 + with: + name: javascript-web-dist + path: javascript-web/dist/ + retention-days: 1 + + browserstack-web: + name: BrowserStack Tests + runs-on: ubuntu-latest + needs: build + timeout-minutes: 150 + outputs: + build_id: ${{ steps.test.outputs.build_id }} + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: "22" + + - name: Download build artifact + uses: actions/download-artifact@v4 + with: + name: javascript-web-dist + path: javascript-web/dist + + - name: Install http-server + run: npm install -g http-server - name: Start web server working-directory: javascript-web run: | - nohup npx http-server dist -p 3000 -c-1 --cors > server.log 2>&1 & + nohup http-server dist -p 3000 -c-1 --cors > server.log 2>&1 & sleep 5 curl -f http://localhost:3000/ || (echo "Server failed to start" && cat server.log && exit 1) echo "Web server started on http://localhost:3000" @@ -89,41 +120,89 @@ jobs: chmod 600 browserstack.yml nohup ./BrowserStackLocal --config-file ./browserstack.yml --daemon start & - sleep 10 + sleep 20 echo "BrowserStack Local tunnel established" + - name: Generate BrowserStack names + id: build-info + uses: ./.github/actions/generate-browserstack-names + - name: Make test script executable run: chmod +x .github/scripts/browserstack-test.py - - name: Execute tests on BrowserStack + - name: Seed and execute tests on BrowserStack + id: test + uses: nick-fields/retry@v3 env: BROWSERSTACK_USERNAME: ${{ secrets.BROWSERSTACK_USERNAME }} BROWSERSTACK_ACCESS_KEY: ${{ secrets.BROWSERSTACK_ACCESS_KEY }} + BROWSERSTACK_PROJECT: ${{ steps.build-info.outputs.project-name }} + BROWSERSTACK_BUILD_NAME: ${{ steps.build-info.outputs.build-name }} GITHUB_RUN_NUMBER: ${{ github.run_number }} GITHUB_RUN_ID: ${{ github.run_id }} - GITHUB_TEST_DOC_ID: ${{ env.GITHUB_TEST_DOC_ID }} - run: | - # Install Python dependencies - pip3 install selenium - - # Run the test script - python3 .github/scripts/browserstack-test.py + 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}_javascript-web_ci_test_${{ github.run_id }}_${{ github.run_number }}\", + \"title\": \"${INVERTED_TIMESTAMP}_javascript-web_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}_javascript-web_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 + + # Set environment variable for Python script + export DITTO_CLOUD_TASK_TITLE="$TASK_TITLE" + + # Install Python dependencies + pip3 install selenium pyyaml + + # Run the test script + python3 .github/scripts/browserstack-test.py + + # Query BrowserStack API to get the build ID + echo "Fetching build ID from BrowserStack..." + BUILDS_RESPONSE=$(curl -s -u "${{ secrets.BROWSERSTACK_USERNAME }}:${{ secrets.BROWSERSTACK_ACCESS_KEY }}" \ + "https://api.browserstack.com/automate/builds.json?limit=1") + + BUILD_ID=$(echo "$BUILDS_RESPONSE" | yq eval '.[0].hashed_id' -) + + if [ "$BUILD_ID" != "null" ] && [ -n "$BUILD_ID" ]; then + echo "build_id=$BUILD_ID" >> $GITHUB_OUTPUT + echo "โœ… Build ID: $BUILD_ID" + else + echo "โš ๏ธ Could not retrieve build ID from BrowserStack API" + fi - name: Stop BrowserStack Local tunnel if: always() run: ./BrowserStackLocal --key "${{ secrets.BROWSERSTACK_ACCESS_KEY }}" --daemon stop || true - - - name: Generate test report - if: always() - run: | - echo "# BrowserStack Web Test Report" > test-report.md - echo "" >> test-report.md - echo "## Tested Browsers" >> test-report.md - echo "- Chrome 120.0 (Windows 11)" >> test-report.md - echo "- Firefox 121.0 (Windows 11)" >> test-report.md - echo "" >> test-report.md - echo "## Sync Verification" >> test-report.md - echo "- GitHub Test Document ID: ${GITHUB_TEST_DOC_ID:-Not generated}" >> test-report.md - name: Upload test artifacts if: always() @@ -133,38 +212,44 @@ jobs: path: | javascript-web/dist/ javascript-web/server.log - test-report.md *screenshot*.png - .github/scripts/browserstack-test.py - - name: Generate test summary - if: always() + summary: + name: CI Report + runs-on: ubuntu-latest + needs: [lint, build, browserstack-web] + if: always() + + steps: + - name: Report Results run: | echo "## ๐ŸŒ JavaScript Web CI" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY # Overall status - if [ "${{ job.status }}" = "success" ]; then + if [[ "${{ needs.lint.result }}" == "success" && \ + "${{ needs.build.result }}" == "success" && \ + "${{ needs.browserstack-web.result }}" == "success" ]]; then echo "**Overall Status:** โœ… All checks passed" >> $GITHUB_STEP_SUMMARY else echo "**Overall Status:** โŒ Failed" >> $GITHUB_STEP_SUMMARY fi echo "" >> $GITHUB_STEP_SUMMARY - echo "| Check | Status |" >> $GITHUB_STEP_SUMMARY - echo "|-------|--------|" >> $GITHUB_STEP_SUMMARY - echo "| Lint | โœ… Passed |" >> $GITHUB_STEP_SUMMARY - echo "| Build | โœ… Passed |" >> $GITHUB_STEP_SUMMARY - echo "| BrowserStack Tests | ${{ job.status == 'success' && 'โœ… Passed' || 'โŒ Failed' }} |" >> $GITHUB_STEP_SUMMARY + echo "| Job | Status |" >> $GITHUB_STEP_SUMMARY + echo "|-----|--------|" >> $GITHUB_STEP_SUMMARY + echo "| Lint | ${{ needs.lint.result == 'success' && 'โœ… Passed' || 'โŒ Failed' }} |" >> $GITHUB_STEP_SUMMARY + echo "| Build | ${{ needs.build.result == 'success' && 'โœ… Passed' || 'โŒ Failed' }} |" >> $GITHUB_STEP_SUMMARY + echo "| BrowserStack Tests | ${{ needs.browserstack-web.result == 'success' && 'โœ… Passed' || 'โŒ Failed' }} |" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY # BrowserStack info - echo "### BrowserStack Session" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "๐Ÿ”— [View Dashboard](https://automate.browserstack.com/dashboard/v2)" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "**Tested Browsers:**" >> $GITHUB_STEP_SUMMARY - echo "- Chrome 120.0 (Windows 11)" >> $GITHUB_STEP_SUMMARY - echo "- Firefox 121.0 (Windows 11)" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "**Test Document ID:** \`${GITHUB_TEST_DOC_ID:-Not generated}\`" >> $GITHUB_STEP_SUMMARY + if [ -n "${{ needs.browserstack-web.outputs.build_id }}" ]; then + echo "### BrowserStack Session" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "๐Ÿ”— [View Test Results](https://automate.browserstack.com/dashboard/v2/builds/${{ needs.browserstack-web.outputs.build_id }}/)" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "**Tested Browsers:**" >> $GITHUB_STEP_SUMMARY + echo "- Chrome 120.0 (Windows 11)" >> $GITHUB_STEP_SUMMARY + echo "- Firefox 121.0 (Windows 11)" >> $GITHUB_STEP_SUMMARY + fi diff --git a/.github/workflows/kotlin-multiplatform-ci.yml b/.github/workflows/kotlin-multiplatform-ci.yml index 845e1456b..47785891d 100644 --- a/.github/workflows/kotlin-multiplatform-ci.yml +++ b/.github/workflows/kotlin-multiplatform-ci.yml @@ -1,22 +1,14 @@ name: Kotlin Multiplatform CI on: - push: - branches: [ main ] - paths: - - 'kotlin-multiplatform/**' - - '.github/workflows/kotlin-multiplatform-ci.yml' pull_request: - branches: [ main ] - paths: - - 'kotlin-multiplatform/**' - - '.github/workflows/kotlin-multiplatform-ci.yml' workflow_dispatch: concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true + jobs: lint: name: Lint (ubuntu-latest) @@ -25,17 +17,17 @@ jobs: defaults: run: working-directory: kotlin-multiplatform - + steps: - uses: actions/checkout@v4 - + - name: Setup Java uses: actions/setup-java@v4 with: distribution: 'temurin' java-version: '17' cache: 'gradle' - + - name: Cache Gradle dependencies uses: actions/cache@v4 with: @@ -46,20 +38,20 @@ jobs: key: gradle-${{ runner.os }}-${{ hashFiles('kotlin-multiplatform/gradle/wrapper/gradle-wrapper.properties', 'kotlin-multiplatform/**/*.gradle*', 'kotlin-multiplatform/gradle/libs.versions.toml') }} restore-keys: | gradle-${{ runner.os }}- - + - name: Create test .env file run: | echo "DITTO_APP_ID=test_app_id" > ../.env echo "DITTO_PLAYGROUND_TOKEN=test_token" >> ../.env echo "DITTO_AUTH_URL=https://test.com" >> ../.env echo "DITTO_WEBSOCKET_URL=wss://test.com" >> ../.env - + - name: Make gradlew executable run: chmod +x gradlew - + - name: Run Detekt lint run: ./gradlew detekt --stacktrace - + - name: Upload Detekt reports uses: actions/upload-artifact@v4 if: always() @@ -165,15 +157,15 @@ jobs: echo "DITTO_PLAYGROUND_TOKEN=${{ secrets.DITTO_PLAYGROUND_TOKEN }}" >> ../.env echo "DITTO_AUTH_URL=${{ secrets.DITTO_AUTH_URL }}" >> ../.env echo "DITTO_WEBSOCKET_URL=${{ secrets.DITTO_WEBSOCKET_URL }}" >> ../.env - + - name: Make gradlew executable run: chmod +x gradlew - + - name: Build KMP iOS frameworks (sim + device) run: | ./gradlew :composeApp:linkDebugFrameworkIosSimulatorArm64 --stacktrace ./gradlew :composeApp:linkDebugFrameworkIosArm64 --stacktrace - + # ---- iOS app build without archive/signing (more reliable on CI) ---- - name: Prepare Xcode container (project/workspace + CocoaPods) run: | @@ -186,7 +178,7 @@ jobs: pod install cd .. fi - + if [ -f "iosApp/iosApp.xcworkspace" ]; then echo "XCWORKSPACE=1" >> $GITHUB_ENV CONTAINER="iosApp/iosApp.xcworkspace" @@ -196,19 +188,19 @@ jobs: CONTAINER="iosApp/iosApp.xcodeproj" CONTAINER_FLAG="-project" fi - + echo "CONTAINER=$CONTAINER" >> $GITHUB_ENV echo "CONTAINER_FLAG=$CONTAINER_FLAG" >> $GITHUB_ENV - + echo "Available schemes:" xcodebuild -list $CONTAINER_FLAG "$CONTAINER" - + - name: Build device .app (no signing) run: | set -euo pipefail DERIVED="$PWD/build/DerivedData" PRODUCTS="$DERIVED/Build/Products" - + xcodebuild \ $CONTAINER_FLAG "$CONTAINER" \ -scheme iosApp \ @@ -219,7 +211,7 @@ jobs: ENABLE_BITCODE=NO \ ONLY_ACTIVE_ARCH=NO \ build - + echo "Searching for device .appโ€ฆ" APP_DIR=$(find "$PRODUCTS/Debug-iphoneos" -maxdepth 1 -type d -name "*.app" | head -1 || true) if [ -z "${APP_DIR:-}" ]; then @@ -229,7 +221,7 @@ jobs: fi echo "APP_DIR=$APP_DIR" >> $GITHUB_ENV echo "โœ… Found app: $APP_DIR" - + - name: Create unsigned .ipa for BrowserStack run: | set -euo pipefail @@ -238,14 +230,14 @@ jobs: (cd build && zip -qry iosApp-unsigned.ipa Payload && rm -rf Payload) test -f build/iosApp-unsigned.ipa || (echo "โŒ IPA not created" && exit 1) ls -la build/iosApp-unsigned.ipa - + - name: Upload iOS IPA Artifact uses: actions/upload-artifact@v4 with: name: ios-kmp-ipa-${{ github.run_number }} path: kotlin-multiplatform/build/iosApp-unsigned.ipa retention-days: 1 - + - name: Set build status id: build-status run: echo "success=true" >> $GITHUB_OUTPUT @@ -257,17 +249,17 @@ jobs: defaults: run: working-directory: kotlin-multiplatform - + steps: - uses: actions/checkout@v4 - + - name: Setup Java uses: actions/setup-java@v4 with: distribution: 'temurin' java-version: '17' cache: 'gradle' - + - name: Cache Gradle dependencies uses: actions/cache@v4 with: @@ -278,20 +270,20 @@ jobs: key: gradle-${{ runner.os }}-${{ hashFiles('kotlin-multiplatform/gradle/wrapper/gradle-wrapper.properties', 'kotlin-multiplatform/**/*.gradle*', 'kotlin-multiplatform/gradle/libs.versions.toml') }} restore-keys: | gradle-${{ runner.os }}- - + - name: Create test .env file run: | echo "DITTO_APP_ID=test_app_id" > ../.env echo "DITTO_PLAYGROUND_TOKEN=test_token" >> ../.env echo "DITTO_AUTH_URL=https://test.com" >> ../.env echo "DITTO_WEBSOCKET_URL=wss://test.com" >> ../.env - + - name: Make gradlew executable run: chmod +x gradlew - + - name: Build Desktop application run: ./gradlew :composeApp:packageDistributionForCurrentOS --stacktrace - + - name: Upload Desktop build outputs uses: actions/upload-artifact@v4 if: always() @@ -306,19 +298,13 @@ jobs: runs-on: ubuntu-latest needs: [build-android] if: github.event_name == 'push' || github.event_name == 'pull_request' || github.event_name == 'workflow_dispatch' - timeout-minutes: 45 - + timeout-minutes: 150 + outputs: + build_id: ${{ steps.test.outputs.build_id }} + steps: - uses: actions/checkout@v4 - - name: Seed test task to Ditto Cloud - id: seed_task - uses: ./.github/actions/seed-ditto-document - with: - ditto-api-key: ${{ secrets.DITTO_API_KEY }} - ditto-api-url: ${{ secrets.DITTO_API_URL }} - app-name: 'kotlin-multiplatform' - - name: Download Android APK artifacts uses: actions/download-artifact@v4 with: @@ -330,157 +316,201 @@ jobs: run: | CREDS="${{ secrets.BROWSERSTACK_USERNAME }}:${{ secrets.BROWSERSTACK_ACCESS_KEY }}" - # 1. Upload AUT (composeApp-debug.apk) - APP_UPLOAD_RESPONSE=$(curl -u "$CREDS" \ + # Upload app APK + echo "Uploading app APK to BrowserStack..." + APP_RESPONSE=$(curl -u "$CREDS" \ -X POST "https://api-cloud.browserstack.com/app-automate/espresso/v2/app" \ -F "file=@kotlin-multiplatform/composeApp/build/outputs/apk/debug/composeApp-debug.apk" \ -F "custom_id=ditto-kotlin-multiplatform-app") - APP_URL=$(echo "$APP_UPLOAD_RESPONSE" | jq -r .app_url) + + APP_URL=$(echo "$APP_RESPONSE" | yq eval -p=json .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_url=$APP_URL" >> "$GITHUB_OUTPUT" + echo "App APK uploaded: $APP_URL" - # 2. Upload Espresso test-suite (composeApp-debug-androidTest.apk) - TEST_UPLOAD_RESPONSE=$(curl -u "$CREDS" \ + # Upload test APK + echo "Uploading test APK to BrowserStack..." + TEST_RESPONSE=$(curl -u "$CREDS" \ -X POST "https://api-cloud.browserstack.com/app-automate/espresso/v2/test-suite" \ -F "file=@kotlin-multiplatform/composeApp/build/outputs/apk/androidTest/debug/composeApp-debug-androidTest.apk" \ -F "custom_id=ditto-kotlin-multiplatform-test") - TEST_URL=$(echo "$TEST_UPLOAD_RESPONSE" | jq -r .test_suite_url) - echo "test_url=$TEST_URL" >> "$GITHUB_OUTPUT" - - - name: Execute tests on BrowserStack - id: test - run: | - # Validate inputs before creating test execution request - APP_URL="${{ steps.upload.outputs.app_url }}" - TEST_URL="${{ steps.upload.outputs.test_url }}" - - echo "App URL: $APP_URL" - echo "Test URL: $TEST_URL" - - if [ -z "$APP_URL" ] || [ "$APP_URL" = "null" ]; then - echo "Error: No valid app URL available" - exit 1 - fi - - if [ -z "$TEST_URL" ] || [ "$TEST_URL" = "null" ]; then - echo "Error: No valid test URL available" - exit 1 - fi - - # Create test execution request with instrumentationOptions - TITLE="${{ steps.seed_task.outputs.document-title }}" - - BUILD_RESPONSE=$(curl -u "${{ secrets.BROWSERSTACK_USERNAME }}:${{ secrets.BROWSERSTACK_ACCESS_KEY }}" \ - -X POST "https://api-cloud.browserstack.com/app-automate/espresso/v2/build" \ - -H "Content-Type: application/json" \ - -d "{ - \"app\": \"$APP_URL\", - \"testSuite\": \"$TEST_URL\", - \"devices\": [ - \"Google Pixel 8-14.0\", - \"Samsung Galaxy S23-13.0\", - \"Google Pixel 6-12.0\" - ], - \"project\": \"Ditto Kotlin Multiplatform\", - \"buildName\": \"Build #${{ github.run_number }}\", - \"buildTag\": \"${{ github.ref_name }}\", - \"deviceLogs\": true, - \"video\": true, - \"networkLogs\": true, - \"autoGrantPermissions\": true, - \"acceptInsecureCerts\": true, - \"enableWebsocketTunneling\": true, - \"instrumentationOptions\": { - \"github_test_doc_title\": \"$TITLE\" - } - }") - - 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_id=$BUILD_ID" >> $GITHUB_OUTPUT - echo "Build started with ID: $BUILD_ID" - - - name: Wait for BrowserStack tests to complete - run: | - BUILD_ID="${{ steps.test.outputs.build_id }}" - - # Validate BUILD_ID before proceeding - if [ "$BUILD_ID" = "null" ] || [ -z "$BUILD_ID" ]; then - echo "Error: No valid BUILD_ID available. Skipping test monitoring." + + TEST_URL=$(echo "$TEST_RESPONSE" | yq eval -p=json .test_suite_url) + if [ "$TEST_URL" = "null" ] || [ -z "$TEST_URL" ]; then + echo "Error: Failed to upload test APK" + echo "Response: $TEST_RESPONSE" exit 1 fi - - MAX_WAIT_TIME=1800 # 30 minutes - CHECK_INTERVAL=30 # Check every 30 seconds - ELAPSED=0 - - while [ $ELAPSED -lt $MAX_WAIT_TIME ]; do - BUILD_STATUS_RESPONSE=$(curl -s -u "${{ secrets.BROWSERSTACK_USERNAME }}:${{ secrets.BROWSERSTACK_ACCESS_KEY }}" \ - "https://api-cloud.browserstack.com/app-automate/espresso/v2/builds/$BUILD_ID") - - BUILD_STATUS=$(echo "$BUILD_STATUS_RESPONSE" | jq -r .status) - - # Check for API errors - if [ "$BUILD_STATUS" = "null" ] || [ -z "$BUILD_STATUS" ]; then - echo "Error getting build status. Response: $BUILD_STATUS_RESPONSE" - sleep $CHECK_INTERVAL - ELAPSED=$((ELAPSED + CHECK_INTERVAL)) - continue - fi - - echo "Build status: $BUILD_STATUS (elapsed: ${ELAPSED}s)" - - # Check for completion states - if [ "$BUILD_STATUS" = "done" ] || [ "$BUILD_STATUS" = "failed" ] || [ "$BUILD_STATUS" = "error" ] || [ "$BUILD_STATUS" = "passed" ] || [ "$BUILD_STATUS" = "completed" ]; then - echo "Build completed with status: $BUILD_STATUS" - break + echo "test_url=$TEST_URL" >> "$GITHUB_OUTPUT" + echo "Test APK uploaded: $TEST_URL" + + - name: Get BrowserStack build info + id: build-info + uses: ./.github/actions/generate-browserstack-names + with: + platform-suffix: ' (Android)' + + - name: Seed and execute tests on BrowserStack + id: test + uses: nick-fields/retry@v3 + env: + BUILD_NAME: ${{ steps.build-info.outputs.build-name }} + PROJECT_NAME: ${{ steps.build-info.outputs.project-name }} + 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}_kotlin-multiplatform_ci_test_${{ github.run_id }}_${{ github.run_number }}\", + \"title\": \"${INVERTED_TIMESTAMP}_kotlin-multiplatform_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}_kotlin-multiplatform_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 - - 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/espresso/v2/builds/$BUILD_ID") - - echo "Final build result:" - echo "$FINAL_RESULT" | jq . - - # Check if we got valid results - if echo "$FINAL_RESULT" | jq -e .devices > /dev/null 2>&1; then - # Check if the overall build passed - BUILD_STATUS=$(echo "$FINAL_RESULT" | jq -r .status) - if [ "$BUILD_STATUS" != "passed" ]; then - echo "Build failed with status: $BUILD_STATUS" + + # Load devices from centralized config + DEVICES=$(yq eval -o=json -I=0 '.["kotlin-multiplatform"].android.devices' .github/browserstack-devices.yml) + + # Use jq to properly escape the build name for JSON + BUILD_NAME_JSON=$(echo "$BUILD_NAME" | jq -Rs .) + PROJECT_NAME_JSON=$(echo "$PROJECT_NAME" | jq -Rs .) + + BUILD_RESPONSE=$(curl -u "${{ secrets.BROWSERSTACK_USERNAME }}:${{ secrets.BROWSERSTACK_ACCESS_KEY }}" \ + -X POST "https://api-cloud.browserstack.com/app-automate/espresso/v2/build" \ + -H "Content-Type: application/json" \ + -d "{ + \"app\": \"${{ steps.upload.outputs.app_url }}\", + \"testSuite\": \"${{ steps.upload.outputs.test_url }}\", + \"devices\": $DEVICES, + \"project\": $PROJECT_NAME_JSON, + \"buildName\": $BUILD_NAME_JSON, + \"buildTag\": \"${{ github.head_ref || github.ref_name }}\", + \"deviceLogs\": true, + \"video\": true, + \"networkLogs\": true, + \"autoGrantPermissions\": true, + \"acceptInsecureCerts\": true, + \"enableWebsocketTunneling\": true, + \"instrumentationOptions\": { + \"DITTO_CLOUD_TASK_TITLE\": \"$TASK_TITLE\" + } + }") + + echo "BrowserStack API Response:" + echo "$BUILD_RESPONSE" + + BUILD_ID=$(echo "$BUILD_RESPONSE" | yq eval .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" + echo "build_id=$BUILD_ID" >> "$GITHUB_OUTPUT" + + # Wait for BrowserStack tests to complete + MAX_WAIT_TIME=1080 # 18 minutes + CHECK_INTERVAL=30 # Check every 30 seconds + ELAPSED=0 + + while [ $ELAPSED -lt $MAX_WAIT_TIME ]; do + BUILD_STATUS_RESPONSE=$(curl -s -u "${{ secrets.BROWSERSTACK_USERNAME }}:${{ secrets.BROWSERSTACK_ACCESS_KEY }}" \ + "https://api-cloud.browserstack.com/app-automate/espresso/v2/builds/$BUILD_ID") + + BUILD_STATUS=$(echo "$BUILD_STATUS_RESPONSE" | yq eval .status) + + # Check for API errors + if [ "$BUILD_STATUS" = "null" ] || [ -z "$BUILD_STATUS" ]; then + echo "Error getting build status. Response: $BUILD_STATUS_RESPONSE" + sleep $CHECK_INTERVAL + ELAPSED=$((ELAPSED + CHECK_INTERVAL)) + continue + fi + + echo "Build status: $BUILD_STATUS (elapsed: ${ELAPSED}s)" + + # Check for completion states + if [ "$BUILD_STATUS" = "done" ] || [ "$BUILD_STATUS" = "failed" ] || [ "$BUILD_STATUS" = "error" ] || [ "$BUILD_STATUS" = "passed" ] || [ "$BUILD_STATUS" = "completed" ]; then + echo "Build completed with status: $BUILD_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/espresso/v2/builds/$BUILD_ID") + + echo "Final build result:" + echo "$FINAL_RESULT" | jq . + + # Check if we got valid results + if echo "$FINAL_RESULT" | jq -e .devices > /dev/null 2>&1; then + # Check if the overall build passed + BUILD_STATUS=$(echo "$FINAL_RESULT" | yq eval .status) + if [ "$BUILD_STATUS" != "passed" ]; then + echo "Build failed with status: $BUILD_STATUS" + + # Check each device for failures + FAILED_TESTS=$(echo "$FINAL_RESULT" | yq eval '.devices[] | select(.sessions[].status != "passed") | .device') + + if [ -n "$FAILED_TESTS" ]; then + echo "Tests failed on devices: $FAILED_TESTS" + fi + + exit 1 + else + echo "All tests passed successfully!" + fi else - echo "All tests passed successfully!" + echo "Warning: Could not parse final results" + echo "Raw response: $FINAL_RESULT" fi - else - echo "Warning: Could not parse final results" - echo "Raw response: $FINAL_RESULT" - fi # TEMPORARILY DISABLED: BrowserStack iOS Testing (will be re-enabled later) # FIXME: Accessibility tags were not working to detect labels on BrowserStack at time of commenting # browserstack-ios job removed for now - build-summary: - name: Build Summary + summary: + name: CI Report runs-on: ubuntu-latest needs: [lint, build-android, build-ios, build-desktop, browserstack-android] if: always() - + steps: - name: Check build results run: | @@ -495,7 +525,7 @@ jobs: echo "| Android BrowserStack | ${{ needs.browserstack-android.result == 'success' && 'โœ… Passed' || (needs.browserstack-android.result == 'skipped' && 'โญ๏ธ Skipped') || 'โŒ Failed' }} |" >> $GITHUB_STEP_SUMMARY echo "| iOS BrowserStack | โญ๏ธ Skipped (Temporarily Disabled) |" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY - + # Check core build results if [[ "${{ needs.lint.result }}" != "success" ]] || \ [[ "${{ needs.build-android.result }}" != "success" ]] || \ @@ -506,7 +536,7 @@ jobs: else echo "โœ… All core builds passed successfully!" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY - + # Testing results summary if [[ "${{ needs.browserstack-android.result }}" == "success" ]]; then echo "โœ… Android BrowserStack testing passed. iOS BrowserStack testing temporarily disabled." >> $GITHUB_STEP_SUMMARY @@ -520,5 +550,5 @@ jobs: echo "" >> $GITHUB_STEP_SUMMARY echo "### BrowserStack Session" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY - echo "๐Ÿค– [View Android Test Results](https://app-automate.browserstack.com/builds?project=Ditto+Kotlin+Multiplatform&build=Build+%23${{ github.run_number }})" >> $GITHUB_STEP_SUMMARY - fi \ No newline at end of file + echo "๐Ÿค– [View Android Test Results](https://app-automate.browserstack.com/dashboard/v2/builds/${{ needs.browserstack-android.outputs.build_id }}/)" >> $GITHUB_STEP_SUMMARY + fi diff --git a/.github/workflows/react-native-ci.yml b/.github/workflows/react-native-ci.yml index 0a951c010..450315a89 100644 --- a/.github/workflows/react-native-ci.yml +++ b/.github/workflows/react-native-ci.yml @@ -2,17 +2,9 @@ name: React Native CI on: pull_request: - branches: - - main - - 'sdk-*' paths: - - 'react-native/**' - push: - branches: - - main - - 'sdk-*' - paths: - - 'react-native/**' + - "react-native/**" + - ".github/workflows/react-native-ci.yml" workflow_dispatch: concurrency: @@ -26,540 +18,621 @@ jobs: timeout-minutes: 10 steps: - - uses: actions/checkout@v4 + - 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: 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: Install dependencies + working-directory: react-native + run: yarn install --frozen-lockfile - - name: Run linting - working-directory: react-native - run: yarn lint + - name: Run linting + working-directory: react-native + run: yarn lint build-android: name: Build Android runs-on: ubuntu-latest needs: lint - timeout-minutes: 30 + timeout-minutes: 150 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: Create .env file (production credentials for builds) - run: | - echo "DITTO_APP_ID=${{ secrets.DITTO_APP_ID }}" > .env - echo "DITTO_PLAYGROUND_TOKEN=${{ secrets.DITTO_PLAYGROUND_TOKEN }}" >> .env - echo "DITTO_AUTH_URL=${{ secrets.DITTO_AUTH_URL }}" >> .env - echo "DITTO_WEBSOCKET_URL=${{ secrets.DITTO_WEBSOCKET_URL }}" >> .env - - - 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 - # Use direct gradlew instead of yarn android to generate unsigned APK required by BrowserStack - # yarn android creates signed builds that don't work with BrowserStack real device testing - run: NODE_ENV=production ./gradlew assembleRelease --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/release/app-release.apk - retention-days: 1 + - 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: Create .env file (production credentials for builds) + run: | + echo "DITTO_APP_ID=${{ secrets.DITTO_APP_ID }}" > .env + echo "DITTO_PLAYGROUND_TOKEN=${{ secrets.DITTO_PLAYGROUND_TOKEN }}" >> .env + echo "DITTO_AUTH_URL=${{ secrets.DITTO_AUTH_URL }}" >> .env + echo "DITTO_WEBSOCKET_URL=${{ secrets.DITTO_WEBSOCKET_URL }}" >> .env + + - 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 + # Use direct gradlew instead of yarn android to generate unsigned APK required by BrowserStack + # yarn android creates signed builds that don't work with BrowserStack real device testing + run: NODE_ENV=production ./gradlew assembleRelease --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/release/app-release.apk + retention-days: 1 build-ios: name: Build iOS runs-on: macos-latest needs: lint - timeout-minutes: 30 + timeout-minutes: 150 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: Create .env file (production credentials for builds) - run: | - echo "DITTO_APP_ID=${{ secrets.DITTO_APP_ID }}" > .env - echo "DITTO_PLAYGROUND_TOKEN=${{ secrets.DITTO_PLAYGROUND_TOKEN }}" >> .env - echo "DITTO_AUTH_URL=${{ secrets.DITTO_AUTH_URL }}" >> .env - echo "DITTO_WEBSOCKET_URL=${{ secrets.DITTO_WEBSOCKET_URL }}" >> .env - - - 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 Archive and IPA (for BrowserStack) - working-directory: react-native - run: | - echo "๐ŸŽ Building iOS device .ipa for BrowserStack..." - - # Use direct xcodebuild instead of yarn ios to generate unsigned IPA required by BrowserStack - # yarn ios creates signed builds for simulators that don't work with BrowserStack real device testing - # Build and archive iOS app for real device - xcodebuild -workspace ios/DittoReactNativeSampleApp.xcworkspace \ - -scheme DittoReactNativeSampleApp \ - -configuration Release \ - -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 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 + - 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: Create .env file (production credentials for builds) + run: | + echo "DITTO_APP_ID=${{ secrets.DITTO_APP_ID }}" > .env + echo "DITTO_PLAYGROUND_TOKEN=${{ secrets.DITTO_PLAYGROUND_TOKEN }}" >> .env + echo "DITTO_AUTH_URL=${{ secrets.DITTO_AUTH_URL }}" >> .env + echo "DITTO_WEBSOCKET_URL=${{ secrets.DITTO_WEBSOCKET_URL }}" >> .env + + - 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 Archive and IPA (for BrowserStack) + working-directory: react-native + run: | + echo "๐ŸŽ Building iOS device .ipa for BrowserStack..." + + # Use direct xcodebuild instead of yarn ios to generate unsigned IPA required by BrowserStack + # yarn ios creates signed builds for simulators that don't work with BrowserStack real device testing + # Build and archive iOS app for real device + xcodebuild -workspace ios/DittoReactNativeSampleApp.xcworkspace \ + -scheme DittoReactNativeSampleApp \ + -configuration Release \ + -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 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 "โŒ Failed to create .ipa file" + echo "โŒ iOS app bundle not found in archive" 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: Create test documents in Big Peer - runs-on: ubuntu-latest - needs: [build-android, build-ios] - timeout-minutes: 10 - outputs: - test_task_title: ${{ steps.seed-document.outputs.document-title }} - steps: - - uses: actions/checkout@v4 - - - name: Insert test document into Ditto Cloud - id: seed-document - uses: ./.github/actions/seed-ditto-document - with: - ditto-api-key: ${{ secrets.DITTO_API_KEY }} - ditto-api-url: ${{ secrets.DITTO_API_URL }} - app-name: 'react-native' - test-android-maestro: + - 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 + + browserstack-android: name: Test Android - BrowserStack Maestro runs-on: ubuntu-latest - needs: [build-android, seed-ditto-cloud] - timeout-minutes: 30 + needs: [build-android] + timeout-minutes: 150 outputs: build_id: ${{ steps.execute-tests.outputs.build_id }} status: ${{ job.status }} # TODO: BrowserStack Android Maestro API environment variables don't work (confirmed by BS staff) # On their roadmap with estimate: Oct 2025. Will revert to setEnvVariables when ready. - # Using sed workaround to replace ${MAESTRO_TASK_TO_FIND} with actual task name in test files + # Using sed workaround to replace ${MAESTRO_DITTO_CLOUD_TASK_TITLE} with actual task name in test files 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-release.apk" \ - -F "custom_id=ReactNative-Android-${{ github.run_id }}") - - 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 - # Create proper BrowserStack folder structure (normal parent folder, not hidden) - rm -rf maestro-tests - mkdir maestro-tests - - # Replace MAESTRO_TASK_TO_FIND with actual seeded task name (BrowserStack Android API bug workaround) - sed 's/\${MAESTRO_TASK_TO_FIND}/${{ needs.seed-ditto-cloud.outputs.test_task_title }}/g' \ - .maestro/flows/01-app-launch-and-seeded-tasks-android.yaml > maestro-tests/01-app-launch-and-seeded-tasks-android.yaml - - # Also create flows/ subfolder for organization - mkdir maestro-tests/flows - cp .maestro/flows/01-app-launch-and-seeded-tasks-android.yaml maestro-tests/flows/ - - # Create ZIP with proper structure - zip -r maestro-tests.zip maestro-tests -x "*.DS_Store" - - - name: Upload Maestro test suite to BrowserStack - id: upload-tests - env: - BROWSERSTACK_USERNAME: ${{ secrets.BROWSERSTACK_USERNAME }} - BROWSERSTACK_ACCESS_KEY: ${{ secrets.BROWSERSTACK_ACCESS_KEY }} - run: | - set -euo pipefail - test -f react-native/maestro-tests.zip - response=$(curl --fail-with-body -u "$BROWSERSTACK_USERNAME:$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 "test_suite_url=$(echo "$response" | jq -r '.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-android.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 + - uses: actions/checkout@v4 + + - name: Download Android APK + uses: actions/download-artifact@v4 + with: + name: android-apk-${{ github.run_number }} + path: ./artifacts/ + + - name: Get BrowserStack build info (Android) + id: build-info-android + uses: ./.github/actions/generate-browserstack-names + with: + platform-suffix: ' (Android)' + title-max-length: '90' + commit-max-length: '130' + + - name: Seed, create test suite, upload, and execute Android tests on BrowserStack + id: execute-tests + 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}_react-native_ci_test_${{ github.run_id }}_${{ github.run_number }}\", + \"title\": \"${INVERTED_TIMESTAMP}_react-native_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}_react-native_ci_test_${{ github.run_id }}_${{ github.run_number }}" + echo "Seeded task: $TASK_TITLE" else - echo "โœ… Android Maestro tests passed!" + echo "Error: Failed to seed task. HTTP Status: $HTTP_CODE" + echo "Response: $BODY" + exit 1 + fi + + # Upload APK to BrowserStack + echo "Uploading APK to BrowserStack..." + 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-release.apk" \ + -F "custom_id=ReactNative-Android-${{ github.run_id }}") + + APP_URL=$(echo $response | yq eval -p=json '.app_url') + if [ -z "$APP_URL" ] || [ "$APP_URL" = "null" ]; then + echo "Error: Failed to upload APK" + echo "Response: $response" + exit 1 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 '.' + echo "APK uploaded: $APP_URL" + + # Create Maestro test suite ZIP with seeded task title + cd react-native + rm -rf maestro-tests + mkdir maestro-tests + + # Replace MAESTRO_DITTO_CLOUD_TASK_TITLE with actual seeded task name + sed "s/\${MAESTRO_DITTO_CLOUD_TASK_TITLE}/$TASK_TITLE/g" \ + .maestro/flows/01-app-launch-and-seeded-tasks-android.yaml > maestro-tests/01-app-launch-and-seeded-tasks-android.yaml + + mkdir maestro-tests/flows + cp maestro-tests/01-app-launch-and-seeded-tasks-android.yaml maestro-tests/flows/ + zip -r maestro-tests.zip maestro-tests -x "*.DS_Store" + cd .. + + # Upload Maestro test suite to BrowserStack + echo "Uploading test suite to BrowserStack..." + 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-Tests-${{ github.run_id }}") + TEST_SUITE_URL=$(echo "$response" | yq eval -p=json '.test_suite_url') + if [ -z "$TEST_SUITE_URL" ] || [ "$TEST_SUITE_URL" = "null" ]; then + echo "Error: Failed to upload test suite" + echo "Response: $response" + exit 1 + fi + echo "Test suite uploaded: $TEST_SUITE_URL" + + # Execute tests + # Load devices from centralized config + DEVICES=$(yq eval -o=json -I=0 '.["react-native"].android.devices' .github/browserstack-devices.yml) + echo "Loaded devices: $DEVICES" + + 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\": \"$APP_URL\", + \"testSuite\": \"$TEST_SUITE_URL\", + \"project\": \"${{ steps.build-info-android.outputs.project-name }}\", + \"buildName\": \"${{ steps.build-info-android.outputs.build-name }}\", + \"buildTag\": \"${{ github.head_ref || github.ref_name }}\", + \"devices\": $DEVICES, + \"execute\": [\"01-app-launch-and-seeded-tasks-android.yaml\"], + \"deviceLogs\": true, + \"networkLogs\": true, + \"video\": true + }") + + echo "Execution response: $response" + build_id=$(echo $response | yq eval '.build_id') + + if [ "$build_id" = "null" ] || [ -z "$build_id" ]; then + echo "โŒ Failed to create BrowserStack build" + echo "Response: $response" + exit 1 + fi + + echo "BrowserStack Build ID: $build_id" + + # Wait for test completion and get results + MAX_WAIT_TIME=1080 # 18 minutes + CHECK_INTERVAL=10 # Check every 10 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/maestro/v2/builds/$build_id") + + status=$(echo $response | yq eval '.status') + echo "Test status: $status (${ELAPSED}s elapsed)" + + 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 | yq eval '.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 - done - exit 1 - fi - sleep 10 - done + sleep $CHECK_INTERVAL + ELAPSED=$((ELAPSED + CHECK_INTERVAL)) + done - if [ $i -eq 120 ]; then - echo "โŒ Tests timed out after 20 minutes" - exit 1 - fi + if [ $ELAPSED -ge $MAX_WAIT_TIME ]; then + echo "โŒ Tests timed out after ${MAX_WAIT_TIME}s (18 minutes)" + exit 1 + fi - test-ios-maestro: + browserstack-ios: name: Test iOS - BrowserStack Maestro runs-on: ubuntu-latest - needs: [build-ios, seed-ditto-cloud] - timeout-minutes: 30 + needs: [build-ios] + timeout-minutes: 150 outputs: build_id: ${{ steps.execute-tests.outputs.build_id }} status: ${{ job.status }} 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 }}") - - 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 - # Create proper BrowserStack folder structure (normal parent folder, not hidden) - rm -rf maestro-tests - mkdir maestro-tests - - # Copy only iOS-specific tests to root level (avoid conflicting appIds) - cp .maestro/flows/01-app-launch-and-seeded-tasks-ios.yaml maestro-tests/ - - # Also create flows/ subfolder for organization - mkdir maestro-tests/flows - cp .maestro/flows/01-app-launch-and-seeded-tasks-ios.yaml maestro-tests/flows/ - - # Create ZIP with proper structure - zip -r maestro-tests.zip maestro-tests -x "*.DS_Store" - - - 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 }}") - - 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"], - "setEnvVariables": { - "MAESTRO_TASK_TO_FIND": "${{ needs.seed-ditto-cloud.outputs.test_task_title }}" - }, - "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 + - uses: actions/checkout@v4 + + - name: Download iOS IPA + uses: actions/download-artifact@v4 + with: + name: ios-ipa-${{ github.run_number }} + path: ./artifacts/ + + - name: Get BrowserStack build info (iOS) + id: build-info-ios + uses: ./.github/actions/generate-browserstack-names + with: + platform-suffix: ' (iOS)' + title-max-length: '90' + commit-max-length: '130' + + - name: Seed, upload, and execute iOS Maestro tests on BrowserStack + id: execute-tests + 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}_react-native_ci_test_${{ github.run_id }}_${{ github.run_number }}\", + \"title\": \"${INVERTED_TIMESTAMP}_react-native_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}_react-native_ci_test_${{ github.run_id }}_${{ github.run_number }}" + echo "Seeded task: $TASK_TITLE" else - echo "โœ… iOS Maestro tests passed!" + echo "Error: Failed to seed task. HTTP Status: $HTTP_CODE" + echo "Response: $BODY" + exit 1 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 '.' + + # Upload IPA to BrowserStack + echo "Uploading IPA to BrowserStack..." + 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 }}") + + APP_URL=$(echo $response | yq eval -p=json '.app_url') + if [ -z "$APP_URL" ] || [ "$APP_URL" = "null" ]; then + echo "Error: Failed to upload IPA" + echo "Response: $response" + exit 1 + fi + echo "IPA uploaded: $APP_URL" + + # Create Maestro test suite ZIP + cd react-native + rm -rf maestro-tests + mkdir maestro-tests + + # Copy iOS-specific test + cp .maestro/flows/01-app-launch-and-seeded-tasks-ios.yaml maestro-tests/ + + # Create flows/ subfolder + mkdir maestro-tests/flows + cp .maestro/flows/01-app-launch-and-seeded-tasks-ios.yaml maestro-tests/flows/ + + # Create ZIP + zip -r maestro-tests.zip maestro-tests -x "*.DS_Store" + cd .. + + # Upload Maestro test suite to BrowserStack + echo "Uploading test suite to BrowserStack..." + 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 }}") + + TEST_SUITE_URL=$(echo $response | yq eval '.test_suite_url') + if [ -z "$TEST_SUITE_URL" ] || [ "$TEST_SUITE_URL" = "null" ]; then + echo "Error: Failed to upload test suite" + echo "Response: $response" + exit 1 + fi + echo "Test suite uploaded: $TEST_SUITE_URL" + + # Execute tests + # Load devices from centralized config + DEVICES=$(yq eval -o=json -I=0 '.["react-native"].ios.devices' .github/browserstack-devices.yml) + echo "Loaded devices: $DEVICES" + + 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\": \"$APP_URL\", + \"testSuite\": \"$TEST_SUITE_URL\", + \"project\": \"${{ steps.build-info-ios.outputs.project-name }}\", + \"buildName\": \"${{ steps.build-info-ios.outputs.build-name }}\", + \"buildTag\": \"${{ github.head_ref || github.ref_name }}\", + \"devices\": $DEVICES, + \"execute\": [\"01-app-launch-and-seeded-tasks-ios.yaml\"], + \"setEnvVariables\": { + \"MAESTRO_DITTO_CLOUD_TASK_TITLE\": \"$TASK_TITLE\" + }, + \"deviceLogs\": true, + \"networkLogs\": true, + \"video\": true + }") + + echo "Execution response: $response" + build_id=$(echo $response | yq eval '.build_id') + + if [ "$build_id" = "null" ] || [ -z "$build_id" ]; then + echo "โŒ Failed to create BrowserStack build" + echo "Response: $response" + exit 1 + fi + + echo "BrowserStack Build ID: $build_id" + + # Wait for test completion and get results + MAX_WAIT_TIME=1080 # 18 minutes + CHECK_INTERVAL=10 # Check every 10 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/maestro/v2/builds/$build_id") + + status=$(echo $response | yq eval '.status') + echo "Test status: $status (${ELAPSED}s elapsed)" + + 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 | yq eval '.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 - done - exit 1 - fi - sleep 10 - done + sleep $CHECK_INTERVAL + ELAPSED=$((ELAPSED + CHECK_INTERVAL)) + done - if [ $i -eq 120 ]; then - echo "โŒ Tests timed out after 20 minutes" - exit 1 - fi + if [ $ELAPSED -ge $MAX_WAIT_TIME ]; then + echo "โŒ Tests timed out after ${MAX_WAIT_TIME}s (18 minutes)" + exit 1 + fi - report-results: - name: Report BrowserStack Results + summary: + name: CI Report runs-on: ubuntu-latest - needs: [test-android-maestro, test-ios-maestro] + needs: [browserstack-android, browserstack-ios] if: always() steps: - - name: Generate BrowserStack Summary - run: | - echo "## ๐Ÿ“ฑ React Native - BrowserStack Maestro Test Results" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - - # Android results - echo "### Android Tests" >> $GITHUB_STEP_SUMMARY - ANDROID_BUILD_ID="${{ needs.test-android-maestro.outputs.build_id }}" - ANDROID_STATUS="${{ needs.test-android-maestro.outputs.status }}" - - if [ -n "$ANDROID_BUILD_ID" ] && [ "$ANDROID_BUILD_ID" != "null" ]; then - if [ "$ANDROID_STATUS" = "success" ]; then - echo "โœ… **Status:** Passed" >> $GITHUB_STEP_SUMMARY + - name: Generate BrowserStack Summary + run: | + echo "## ๐Ÿ“ฑ React Native - BrowserStack Maestro Test Results" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + + # Android results + echo "### Android Tests" >> $GITHUB_STEP_SUMMARY + ANDROID_BUILD_ID="${{ needs.browserstack-android.outputs.build_id }}" + ANDROID_STATUS="${{ needs.browserstack-android.outputs.status }}" + + if [ -n "$ANDROID_BUILD_ID" ] && [ "$ANDROID_BUILD_ID" != "null" ]; then + if [ "$ANDROID_STATUS" = "success" ]; then + echo "โœ… **Status:** Passed" >> $GITHUB_STEP_SUMMARY + else + echo "โŒ **Status:** Failed" >> $GITHUB_STEP_SUMMARY + fi + echo "๐Ÿ”— **[View Dashboard](https://app-automate.browserstack.com/dashboard/v2/builds/$ANDROID_BUILD_ID)**" >> $GITHUB_STEP_SUMMARY + echo "Build ID: \`$ANDROID_BUILD_ID\`" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY else - echo "โŒ **Status:** Failed" >> $GITHUB_STEP_SUMMARY + echo "โŒ **Status:** Failed (No build created)" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY fi - echo "๐Ÿ”— **[View Dashboard](https://app-automate.browserstack.com/dashboard/v2/builds/$ANDROID_BUILD_ID)**" >> $GITHUB_STEP_SUMMARY - echo "Build ID: \`$ANDROID_BUILD_ID\`" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - else - echo "โŒ **Status:** Failed (No build created)" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - fi - # iOS results - echo "### iOS Tests" >> $GITHUB_STEP_SUMMARY - IOS_BUILD_ID="${{ needs.test-ios-maestro.outputs.build_id }}" - IOS_STATUS="${{ needs.test-ios-maestro.outputs.status }}" + # iOS results + echo "### iOS Tests" >> $GITHUB_STEP_SUMMARY + IOS_BUILD_ID="${{ needs.browserstack-ios.outputs.build_id }}" + IOS_STATUS="${{ needs.browserstack-ios.outputs.status }}" - if [ -n "$IOS_BUILD_ID" ] && [ "$IOS_BUILD_ID" != "null" ]; then - if [ "$IOS_STATUS" = "success" ]; then - echo "โœ… **Status:** Passed" >> $GITHUB_STEP_SUMMARY + if [ -n "$IOS_BUILD_ID" ] && [ "$IOS_BUILD_ID" != "null" ]; then + if [ "$IOS_STATUS" = "success" ]; then + echo "โœ… **Status:** Passed" >> $GITHUB_STEP_SUMMARY + else + echo "โŒ **Status:** Failed" >> $GITHUB_STEP_SUMMARY + fi + echo "๐Ÿ”— **[View Dashboard](https://app-automate.browserstack.com/dashboard/v2/builds/$IOS_BUILD_ID)**" >> $GITHUB_STEP_SUMMARY + echo "Build ID: \`$IOS_BUILD_ID\`" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY else - echo "โŒ **Status:** Failed" >> $GITHUB_STEP_SUMMARY + echo "โŒ **Status:** Failed (No build created)" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + fi + + # Summary + echo "---" >> $GITHUB_STEP_SUMMARY + if [ "$ANDROID_STATUS" = "success" ] && [ "$IOS_STATUS" = "success" ]; then + echo "โœ… **Overall Result:** All tests passed" >> $GITHUB_STEP_SUMMARY + else + echo "โš ๏ธ **Overall Result:** Some tests failed" >> $GITHUB_STEP_SUMMARY fi - echo "๐Ÿ”— **[View Dashboard](https://app-automate.browserstack.com/dashboard/v2/builds/$IOS_BUILD_ID)**" >> $GITHUB_STEP_SUMMARY - echo "Build ID: \`$IOS_BUILD_ID\`" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - else - echo "โŒ **Status:** Failed (No build created)" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - fi - - # Summary - echo "---" >> $GITHUB_STEP_SUMMARY - if [ "$ANDROID_STATUS" = "success" ] && [ "$IOS_STATUS" = "success" ]; then - echo "โœ… **Overall Result:** All tests passed" >> $GITHUB_STEP_SUMMARY - else - echo "โš ๏ธ **Overall Result:** Some tests failed" >> $GITHUB_STEP_SUMMARY - fi diff --git a/.github/workflows/react-native-expo-ci.yml b/.github/workflows/react-native-expo-ci.yml index 0b4d5a8b1..2a936e2e1 100644 --- a/.github/workflows/react-native-expo-ci.yml +++ b/.github/workflows/react-native-expo-ci.yml @@ -1,18 +1,10 @@ name: React Native Expo CI on: - push: - branches: - - main - - 'sdk-*' - paths: - - 'react-native-expo/**' pull_request: - branches: - - main - - 'sdk-*' paths: - - 'react-native-expo/**' + - "react-native-expo/**" + - ".github/workflows/react-native-expo-ci.yml" workflow_dispatch: concurrency: @@ -26,552 +18,630 @@ jobs: timeout-minutes: 10 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v4 - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: '20' - cache: 'yarn' - cache-dependency-path: react-native-expo/yarn.lock + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "20" + cache: "yarn" + cache-dependency-path: react-native-expo/yarn.lock - - name: Install dependencies - working-directory: react-native-expo - run: yarn install --frozen-lockfile + - name: Install dependencies + working-directory: react-native-expo + run: yarn install --frozen-lockfile - - name: Run linting - working-directory: react-native-expo - run: yarn lint + - name: Run linting + working-directory: react-native-expo + run: yarn lint build-android: name: Build Android runs-on: ubuntu-latest needs: lint - timeout-minutes: 30 + timeout-minutes: 150 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-expo/yarn.lock - - - name: Install dependencies - working-directory: react-native-expo - 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: Create .env file (production credentials for builds) - run: | - echo "DITTO_APP_ID=${{ secrets.DITTO_APP_ID }}" > .env - echo "DITTO_PLAYGROUND_TOKEN=${{ secrets.DITTO_PLAYGROUND_TOKEN }}" >> .env - echo "DITTO_AUTH_URL=${{ secrets.DITTO_AUTH_URL }}" >> .env - echo "DITTO_WEBSOCKET_URL=${{ secrets.DITTO_WEBSOCKET_URL }}" >> .env - - - name: Cache Gradle dependencies - uses: actions/cache@v4 - with: - path: | - ~/.gradle/caches - ~/.gradle/wrapper - react-native-expo/android/.gradle - key: gradle-expo-${{ runner.os }}-${{ hashFiles('react-native-expo/android/gradle/wrapper/gradle-wrapper.properties', 'react-native-expo/android/**/*.gradle*') }} - restore-keys: | - gradle-expo-${{ runner.os }}- - - - name: Prebuild Android project - working-directory: react-native-expo - run: npx expo prebuild --platform android - - - name: Build Android APK - working-directory: react-native-expo/android - # Use direct gradlew instead of yarn android to generate unsigned APK required by BrowserStack - # yarn android creates signed builds that don't work with BrowserStack real device testing - run: NODE_ENV=production ./gradlew assembleRelease --no-daemon --console=plain - - - name: Upload Android APK artifact - uses: actions/upload-artifact@v4 - with: - name: expo-android-apk-${{ github.run_number }} - path: react-native-expo/android/app/build/outputs/apk/release/app-release.apk - retention-days: 1 + - uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "20" + cache: "yarn" + cache-dependency-path: react-native-expo/yarn.lock + + - name: Install dependencies + working-directory: react-native-expo + 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: Create .env file (production credentials for builds) + run: | + echo "DITTO_APP_ID=${{ secrets.DITTO_APP_ID }}" > .env + echo "DITTO_PLAYGROUND_TOKEN=${{ secrets.DITTO_PLAYGROUND_TOKEN }}" >> .env + echo "DITTO_AUTH_URL=${{ secrets.DITTO_AUTH_URL }}" >> .env + echo "DITTO_WEBSOCKET_URL=${{ secrets.DITTO_WEBSOCKET_URL }}" >> .env + + - name: Cache Gradle dependencies + uses: actions/cache@v4 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + react-native-expo/android/.gradle + key: gradle-expo-${{ runner.os }}-${{ hashFiles('react-native-expo/android/gradle/wrapper/gradle-wrapper.properties', 'react-native-expo/android/**/*.gradle*') }} + restore-keys: | + gradle-expo-${{ runner.os }}- + + - name: Prebuild Android project + working-directory: react-native-expo + run: npx expo prebuild --platform android + + - name: Build Android APK + working-directory: react-native-expo/android + # Use direct gradlew instead of yarn android to generate unsigned APK required by BrowserStack + # yarn android creates signed builds that don't work with BrowserStack real device testing + run: NODE_ENV=production ./gradlew assembleRelease --no-daemon --console=plain + + - name: Upload Android APK artifact + uses: actions/upload-artifact@v4 + with: + name: expo-android-apk-${{ github.run_number }} + path: react-native-expo/android/app/build/outputs/apk/release/app-release.apk + retention-days: 1 build-ios: name: Build iOS runs-on: macos-latest needs: lint - timeout-minutes: 30 + timeout-minutes: 150 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-expo/yarn.lock - - - name: Install dependencies - working-directory: react-native-expo - run: yarn install --frozen-lockfile - - - name: Create .env file (production credentials for builds) - run: | - echo "DITTO_APP_ID=${{ secrets.DITTO_APP_ID }}" > .env - echo "DITTO_PLAYGROUND_TOKEN=${{ secrets.DITTO_PLAYGROUND_TOKEN }}" >> .env - echo "DITTO_AUTH_URL=${{ secrets.DITTO_AUTH_URL }}" >> .env - echo "DITTO_WEBSOCKET_URL=${{ secrets.DITTO_WEBSOCKET_URL }}" >> .env - - - name: Cache CocoaPods dependencies - uses: actions/cache@v4 - with: - path: | - ~/Library/Caches/CocoaPods - ~/.cocoapods - key: cocoapods-expo-${{ runner.os }}-${{ hashFiles('react-native-expo/ios/Podfile.lock') }} - restore-keys: | - cocoapods-expo-${{ runner.os }}- - - - name: Prebuild iOS project - working-directory: react-native-expo - run: npx expo prebuild --platform ios - - - name: Setup Xcode - uses: maxim-lobanov/setup-xcode@v1 - with: - xcode-version: '16.4' - - - name: Install CocoaPods - working-directory: react-native-expo/ios - run: pod install --repo-update - - - name: Build iOS Archive and IPA (for BrowserStack) - working-directory: react-native-expo - run: | - echo "๐ŸŽ Building iOS device .ipa for BrowserStack..." - - # Use direct xcodebuild instead of yarn ios to generate unsigned IPA required by BrowserStack - # yarn ios creates signed builds for simulators that don't work with BrowserStack real device testing - # Build and archive iOS app for real device - xcodebuild -workspace ios/reactnativeexpo.xcworkspace \ - -scheme reactnativeexpo \ - -configuration Release \ - -destination 'generic/platform=iOS' \ - -archivePath ios/build/reactnativeexpo.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/reactnativeexpo.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 zipped as .ipa - mkdir -p ios/build/Payload - cp -R "$APP_BUNDLE_PATH" ios/build/Payload/ - (cd ios/build && zip -qry reactnativeexpo-unsigned.ipa Payload && rm -rf Payload) - - if [ -f "ios/build/reactnativeexpo-unsigned.ipa" ]; then - echo "โœ… Unsigned .ipa created successfully" - ls -la ios/build/reactnativeexpo-unsigned.ipa + - uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "20" + cache: "yarn" + cache-dependency-path: react-native-expo/yarn.lock + + - name: Install dependencies + working-directory: react-native-expo + run: yarn install --frozen-lockfile + + - name: Create .env file (production credentials for builds) + run: | + echo "DITTO_APP_ID=${{ secrets.DITTO_APP_ID }}" > .env + echo "DITTO_PLAYGROUND_TOKEN=${{ secrets.DITTO_PLAYGROUND_TOKEN }}" >> .env + echo "DITTO_AUTH_URL=${{ secrets.DITTO_AUTH_URL }}" >> .env + echo "DITTO_WEBSOCKET_URL=${{ secrets.DITTO_WEBSOCKET_URL }}" >> .env + + - name: Cache CocoaPods dependencies + uses: actions/cache@v4 + with: + path: | + ~/Library/Caches/CocoaPods + ~/.cocoapods + key: cocoapods-expo-${{ runner.os }}-${{ hashFiles('react-native-expo/ios/Podfile.lock') }} + restore-keys: | + cocoapods-expo-${{ runner.os }}- + + - name: Prebuild iOS project + working-directory: react-native-expo + run: npx expo prebuild --platform ios + + - name: Setup Xcode + uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: "16.4" + + - name: Install CocoaPods + working-directory: react-native-expo/ios + run: pod install --repo-update + + - name: Build iOS Archive and IPA (for BrowserStack) + working-directory: react-native-expo + run: | + echo "๐ŸŽ Building iOS device .ipa for BrowserStack..." + + # Use direct xcodebuild instead of yarn ios to generate unsigned IPA required by BrowserStack + # yarn ios creates signed builds for simulators that don't work with BrowserStack real device testing + # Build and archive iOS app for real device + xcodebuild -workspace ios/reactnativeexpo.xcworkspace \ + -scheme reactnativeexpo \ + -configuration Release \ + -destination 'generic/platform=iOS' \ + -archivePath ios/build/reactnativeexpo.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/reactnativeexpo.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 zipped as .ipa + mkdir -p ios/build/Payload + cp -R "$APP_BUNDLE_PATH" ios/build/Payload/ + (cd ios/build && zip -qry reactnativeexpo-unsigned.ipa Payload && rm -rf Payload) + + if [ -f "ios/build/reactnativeexpo-unsigned.ipa" ]; then + echo "โœ… Unsigned .ipa created successfully" + ls -la ios/build/reactnativeexpo-unsigned.ipa + else + echo "โŒ Failed to create .ipa file" + exit 1 + fi else - echo "โŒ Failed to create .ipa file" + echo "โŒ iOS app bundle not found in archive" 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: expo-ios-ipa-${{ github.run_number }} - path: react-native-expo/ios/build/reactnativeexpo-unsigned.ipa - retention-days: 1 - - seed-ditto-cloud: - name: Create test documents in Big Peer - runs-on: ubuntu-latest - needs: [build-android, build-ios] - timeout-minutes: 10 - outputs: - test_task_title: ${{ steps.seed-document.outputs.document-title }} - steps: - - uses: actions/checkout@v4 - - - name: Insert test document into Ditto Cloud - id: seed-document - uses: ./.github/actions/seed-ditto-document - with: - ditto-api-key: ${{ secrets.DITTO_API_KEY }} - ditto-api-url: ${{ secrets.DITTO_API_URL }} - app-name: 'react-native-expo' + - name: Upload iOS IPA artifact + uses: actions/upload-artifact@v4 + with: + name: expo-ios-ipa-${{ github.run_number }} + path: react-native-expo/ios/build/reactnativeexpo-unsigned.ipa + retention-days: 1 - test-android-maestro: + browserstack-android: name: Test Android - BrowserStack Maestro runs-on: ubuntu-latest - needs: [build-android, seed-ditto-cloud] - timeout-minutes: 30 + needs: [build-android] + timeout-minutes: 150 outputs: build_id: ${{ steps.execute-tests.outputs.build_id }} status: ${{ job.status }} # TODO: BrowserStack Android Maestro API environment variables don't work (confirmed by BS staff) # On their roadmap with estimate: Oct 2025. Will revert to setEnvVariables when ready. - # Using sed workaround to replace ${MAESTRO_TASK_TO_FIND} with actual task name in test files + # Using sed workaround to replace ${MAESTRO_DITTO_CLOUD_TASK_TITLE} with actual task name in test files steps: - - uses: actions/checkout@v4 - - - name: Download Android APK - uses: actions/download-artifact@v4 - with: - name: expo-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-release.apk" \ - -F "custom_id=ReactNative-Expo-Android-${{ github.run_id }}") - - 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-expo - # Create proper BrowserStack folder structure (normal parent folder, not hidden) - rm -rf maestro-tests - mkdir maestro-tests - - # Replace MAESTRO_TASK_TO_FIND with actual seeded task name (BrowserStack Android API bug workaround) - sed 's/\${MAESTRO_TASK_TO_FIND}/${{ needs.seed-ditto-cloud.outputs.test_task_title }}/g' \ - .maestro/flows/01-app-launch-and-seeded-tasks-android.yaml > maestro-tests/01-app-launch-and-seeded-tasks-android.yaml - - # Also create flows/ subfolder for organization - mkdir maestro-tests/flows - cp .maestro/flows/01-app-launch-and-seeded-tasks-android.yaml maestro-tests/flows/ - - # Create ZIP with proper structure - zip -r maestro-tests.zip maestro-tests -x "*.DS_Store" - - - name: Upload Maestro test suite to BrowserStack - id: upload-tests - run: | - 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-expo/maestro-tests.zip" \ - -F "custom_id=ReactNative-Expo-Tests-${{ github.run_id }}") - - 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 Expo", - "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-android.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 + - uses: actions/checkout@v4 + + - name: Download Android APK + uses: actions/download-artifact@v4 + with: + name: expo-android-apk-${{ github.run_number }} + path: ./artifacts/ + + - name: Get BrowserStack build info (Android) + id: build-info-android + uses: ./.github/actions/generate-browserstack-names + with: + platform-suffix: ' (Android)' + title-max-length: '90' + commit-max-length: '130' + + - name: Seed, create test suite, upload, and execute Android Maestro tests + id: execute-tests + 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}_react-native-expo_ci_test_${{ github.run_id }}_${{ github.run_number }}\", + \"title\": \"${INVERTED_TIMESTAMP}_react-native-expo_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}_react-native-expo_ci_test_${{ github.run_id }}_${{ github.run_number }}" + echo "Seeded task: $TASK_TITLE" else - echo "โœ… Android Maestro tests passed!" + echo "Error: Failed to seed task. HTTP Status: $HTTP_CODE" + echo "Response: $BODY" + exit 1 + fi + + # Upload APK to BrowserStack + echo "Uploading APK to BrowserStack..." + 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-release.apk" \ + -F "custom_id=ReactNative-Expo-Android-${{ github.run_id }}") + + APP_URL=$(echo $response | yq eval -p=json '.app_url') + if [ -z "$APP_URL" ] || [ "$APP_URL" = "null" ]; then + echo "Error: Failed to upload APK" + echo "Response: $response" + exit 1 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 '.' + echo "APK uploaded: $APP_URL" + + # Create Maestro test suite ZIP with sed replacement + cd react-native-expo + rm -rf maestro-tests + mkdir maestro-tests + + # Replace MAESTRO_DITTO_CLOUD_TASK_TITLE with actual seeded task name (BrowserStack Android API bug workaround) + sed "s/\${MAESTRO_DITTO_CLOUD_TASK_TITLE}/$TASK_TITLE/g" \ + .maestro/flows/01-app-launch-and-seeded-tasks-android.yaml > maestro-tests/01-app-launch-and-seeded-tasks-android.yaml + + # Also create flows/ subfolder for organization + mkdir maestro-tests/flows + cp maestro-tests/01-app-launch-and-seeded-tasks-android.yaml maestro-tests/flows/ + + # Create ZIP with proper structure + zip -r maestro-tests.zip maestro-tests -x "*.DS_Store" + cd .. + + # Upload Maestro test suite to BrowserStack + echo "Uploading test suite to BrowserStack..." + 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-expo/maestro-tests.zip" \ + -F "custom_id=ReactNative-Expo-Tests-${{ github.run_id }}") + + TEST_SUITE_URL=$(echo $response | yq eval -p=json '.test_suite_url') + if [ -z "$TEST_SUITE_URL" ] || [ "$TEST_SUITE_URL" = "null" ]; then + echo "Error: Failed to upload test suite" + echo "Response: $response" + exit 1 + fi + echo "Test suite uploaded: $TEST_SUITE_URL" + + # Execute tests + # Load devices from centralized config + DEVICES=$(yq eval -o=json -I=0 '.["react-native-expo"].android.devices' .github/browserstack-devices.yml) + echo "Loaded devices: $DEVICES" + + 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\": \"$APP_URL\", + \"testSuite\": \"$TEST_SUITE_URL\", + \"project\": \"${{ steps.build-info-android.outputs.project-name }}\", + \"buildName\": \"${{ steps.build-info-android.outputs.build-name }}\", + \"buildTag\": \"${{ github.head_ref || github.ref_name }}\", + \"devices\": $DEVICES, + \"execute\": [\"01-app-launch-and-seeded-tasks-android.yaml\"], + \"deviceLogs\": true, + \"networkLogs\": true, + \"video\": true + }") + + echo "Execution response: $response" + build_id=$(echo $response | yq eval '.build_id') + + if [ "$build_id" = "null" ] || [ -z "$build_id" ]; then + echo "โŒ Failed to create BrowserStack build" + echo "Response: $response" + exit 1 + fi + + echo "BrowserStack Build ID: $build_id" + echo "build_id=$build_id" >> $GITHUB_OUTPUT + + # Wait for test completion and get results + MAX_WAIT_TIME=1080 # 18 minutes + CHECK_INTERVAL=10 # Check every 10 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/maestro/v2/builds/$build_id") + + status=$(echo $response | yq eval '.status') + echo "Test status: $status (${ELAPSED}s elapsed)" + + 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 | yq eval '.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 - done - exit 1 - fi - sleep 10 - done + sleep $CHECK_INTERVAL + ELAPSED=$((ELAPSED + CHECK_INTERVAL)) + done - if [ $i -eq 120 ]; then - echo "โŒ Tests timed out after 20 minutes" - exit 1 - fi + if [ $ELAPSED -ge $MAX_WAIT_TIME ]; then + echo "โŒ Tests timed out after ${MAX_WAIT_TIME}s (18 minutes)" + exit 1 + fi - test-ios-maestro: + browserstack-ios: name: Test iOS - BrowserStack Maestro runs-on: ubuntu-latest - needs: [build-ios, seed-ditto-cloud] - timeout-minutes: 30 + needs: [build-ios] + timeout-minutes: 150 outputs: build_id: ${{ steps.execute-tests.outputs.build_id }} status: ${{ job.status }} - env: - MAESTRO_TASK_TO_FIND: ${{ needs.seed-ditto-cloud.outputs.test_task_title }} steps: - - uses: actions/checkout@v4 - - - name: Download iOS IPA - uses: actions/download-artifact@v4 - with: - name: expo-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/reactnativeexpo-unsigned.ipa" \ - -F "custom_id=ReactNative-Expo-iOS-${{ github.run_id }}") - - 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-expo - # Create proper BrowserStack folder structure (normal parent folder, not hidden) - rm -rf maestro-tests - mkdir maestro-tests - - # Copy only iOS-specific tests to root level (avoid conflicting appIds) - cp .maestro/flows/01-app-launch-and-seeded-tasks-ios.yaml maestro-tests/ - - # Also create flows/ subfolder for organization - mkdir maestro-tests/flows - cp .maestro/flows/01-app-launch-and-seeded-tasks-ios.yaml maestro-tests/flows/ - - # Create ZIP with proper structure - zip -r maestro-tests.zip maestro-tests -x "*.DS_Store" - - - 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-expo/maestro-tests.zip" \ - -F "custom_id=ReactNative-Expo-iOS-Tests-${{ github.run_id }}") - - 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 Expo", - "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"], - "setEnvVariables": { - "MAESTRO_TASK_TO_FIND": "${{ needs.seed-ditto-cloud.outputs.test_task_title }}" - }, - "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 + - uses: actions/checkout@v4 + + - name: Download iOS IPA + uses: actions/download-artifact@v4 + with: + name: expo-ios-ipa-${{ github.run_number }} + path: ./artifacts/ + + - name: Get BrowserStack build info (iOS) + id: build-info-ios + uses: ./.github/actions/generate-browserstack-names + with: + platform-suffix: ' (iOS)' + title-max-length: '90' + commit-max-length: '130' + + - name: Seed, upload, and execute iOS Maestro tests on BrowserStack + id: execute-tests + 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}_react-native-expo_ci_test_${{ github.run_id }}_${{ github.run_number }}\", + \"title\": \"${INVERTED_TIMESTAMP}_react-native-expo_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}_react-native-expo_ci_test_${{ github.run_id }}_${{ github.run_number }}" + echo "Seeded task: $TASK_TITLE" else - echo "โœ… iOS Maestro tests passed!" + echo "Error: Failed to seed task. HTTP Status: $HTTP_CODE" + echo "Response: $BODY" + exit 1 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 '.' + + # Upload IPA to BrowserStack + echo "Uploading IPA to BrowserStack..." + 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/reactnativeexpo-unsigned.ipa" \ + -F "custom_id=ReactNative-Expo-iOS-${{ github.run_id }}") + + APP_URL=$(echo $response | yq eval -p=json '.app_url') + if [ -z "$APP_URL" ] || [ "$APP_URL" = "null" ]; then + echo "Error: Failed to upload IPA" + echo "Response: $response" + exit 1 + fi + echo "IPA uploaded: $APP_URL" + + # Create Maestro test suite ZIP + cd react-native-expo + rm -rf maestro-tests + mkdir maestro-tests + + # Copy only iOS-specific tests to root level + cp .maestro/flows/01-app-launch-and-seeded-tasks-ios.yaml maestro-tests/ + + # Also create flows/ subfolder for organization + mkdir maestro-tests/flows + cp .maestro/flows/01-app-launch-and-seeded-tasks-ios.yaml maestro-tests/flows/ + + # Create ZIP with proper structure + zip -r maestro-tests.zip maestro-tests -x "*.DS_Store" + cd .. + + # Upload Maestro test suite to BrowserStack + echo "Uploading test suite to BrowserStack..." + 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-expo/maestro-tests.zip" \ + -F "custom_id=ReactNative-Expo-iOS-Tests-${{ github.run_id }}") + + TEST_SUITE_URL=$(echo $response | yq eval -p=json '.test_suite_url') + if [ -z "$TEST_SUITE_URL" ] || [ "$TEST_SUITE_URL" = "null" ]; then + echo "Error: Failed to upload test suite" + echo "Response: $response" + exit 1 + fi + echo "Test suite uploaded: $TEST_SUITE_URL" + + # Execute tests + # Load devices from centralized config + DEVICES=$(yq eval -o=json -I=0 '.["react-native-expo"].ios.devices' .github/browserstack-devices.yml) + echo "Loaded devices: $DEVICES" + + 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\": \"$APP_URL\", + \"testSuite\": \"$TEST_SUITE_URL\", + \"project\": \"${{ steps.build-info-ios.outputs.project-name }}\", + \"buildName\": \"${{ steps.build-info-ios.outputs.build-name }}\", + \"buildTag\": \"${{ github.head_ref || github.ref_name }}\", + \"devices\": $DEVICES, + \"execute\": [\"01-app-launch-and-seeded-tasks-ios.yaml\"], + \"setEnvVariables\": { + \"MAESTRO_DITTO_CLOUD_TASK_TITLE\": \"$TASK_TITLE\" + }, + \"deviceLogs\": true, + \"networkLogs\": true, + \"video\": true + }") + + echo "Execution response: $response" + build_id=$(echo $response | yq eval '.build_id') + + if [ "$build_id" = "null" ] || [ -z "$build_id" ]; then + echo "โŒ Failed to create BrowserStack build" + echo "Response: $response" + exit 1 + fi + + echo "BrowserStack Build ID: $build_id" + echo "build_id=$build_id" >> $GITHUB_OUTPUT + + # Wait for test completion and get results + MAX_WAIT_TIME=1080 # 18 minutes + CHECK_INTERVAL=10 # Check every 10 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/maestro/v2/builds/$build_id") + + status=$(echo $response | yq eval '.status') + echo "Test status: $status (${ELAPSED}s elapsed)" + + 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 | yq eval '.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 - done - exit 1 - fi - sleep 10 - done + sleep $CHECK_INTERVAL + ELAPSED=$((ELAPSED + CHECK_INTERVAL)) + done - if [ $i -eq 120 ]; then - echo "โŒ Tests timed out after 20 minutes" - exit 1 - fi + if [ $ELAPSED -ge $MAX_WAIT_TIME ]; then + echo "โŒ Tests timed out after ${MAX_WAIT_TIME}s (18 minutes)" + exit 1 + fi - report-results: - name: Report BrowserStack Results + summary: + name: CI Report runs-on: ubuntu-latest - needs: [test-android-maestro, test-ios-maestro] + needs: [browserstack-android, browserstack-ios] if: always() steps: - - name: Generate BrowserStack Summary - run: | - echo "## ๐Ÿ“ฑ React Native Expo - BrowserStack Maestro Test Results" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - - # Android results - echo "### Android Tests" >> $GITHUB_STEP_SUMMARY - ANDROID_BUILD_ID="${{ needs.test-android-maestro.outputs.build_id }}" - ANDROID_STATUS="${{ needs.test-android-maestro.outputs.status }}" - - if [ -n "$ANDROID_BUILD_ID" ] && [ "$ANDROID_BUILD_ID" != "null" ]; then - if [ "$ANDROID_STATUS" = "success" ]; then - echo "โœ… **Status:** Passed" >> $GITHUB_STEP_SUMMARY + - name: Generate BrowserStack Summary + run: | + echo "## ๐Ÿ“ฑ React Native Expo - BrowserStack Maestro Test Results" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + + # Android results + echo "### Android Tests" >> $GITHUB_STEP_SUMMARY + ANDROID_BUILD_ID="${{ needs.browserstack-android.outputs.build_id }}" + ANDROID_RESULT="${{ needs.browserstack-android.result }}" + + if [ -n "$ANDROID_BUILD_ID" ] && [ "$ANDROID_BUILD_ID" != "null" ]; then + if [ "$ANDROID_RESULT" = "success" ]; then + echo "โœ… **Status:** Passed" >> $GITHUB_STEP_SUMMARY + else + echo "โŒ **Status:** Failed" >> $GITHUB_STEP_SUMMARY + fi + echo "๐Ÿ”— **[View Dashboard](https://app-automate.browserstack.com/dashboard/v2/builds/$ANDROID_BUILD_ID/)**" >> $GITHUB_STEP_SUMMARY + echo "Build ID: \`$ANDROID_BUILD_ID\`" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY else - echo "โŒ **Status:** Failed" >> $GITHUB_STEP_SUMMARY + echo "โŒ **Status:** Failed (No build created)" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY fi - echo "๐Ÿ”— **[View Dashboard](https://app-automate.browserstack.com/dashboard/v2/builds/$ANDROID_BUILD_ID)**" >> $GITHUB_STEP_SUMMARY - echo "Build ID: \`$ANDROID_BUILD_ID\`" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - else - echo "โŒ **Status:** Failed (No build created)" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - fi - # iOS results - echo "### iOS Tests" >> $GITHUB_STEP_SUMMARY - IOS_BUILD_ID="${{ needs.test-ios-maestro.outputs.build_id }}" - IOS_STATUS="${{ needs.test-ios-maestro.outputs.status }}" + # iOS results + echo "### iOS Tests" >> $GITHUB_STEP_SUMMARY + IOS_BUILD_ID="${{ needs.browserstack-ios.outputs.build_id }}" + IOS_RESULT="${{ needs.browserstack-ios.result }}" - if [ -n "$IOS_BUILD_ID" ] && [ "$IOS_BUILD_ID" != "null" ]; then - if [ "$IOS_STATUS" = "success" ]; then - echo "โœ… **Status:** Passed" >> $GITHUB_STEP_SUMMARY + if [ -n "$IOS_BUILD_ID" ] && [ "$IOS_BUILD_ID" != "null" ]; then + if [ "$IOS_RESULT" = "success" ]; then + echo "โœ… **Status:** Passed" >> $GITHUB_STEP_SUMMARY + else + echo "โŒ **Status:** Failed" >> $GITHUB_STEP_SUMMARY + fi + echo "๐Ÿ”— **[View Dashboard](https://app-automate.browserstack.com/dashboard/v2/builds/$IOS_BUILD_ID/)**" >> $GITHUB_STEP_SUMMARY + echo "Build ID: \`$IOS_BUILD_ID\`" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY else - echo "โŒ **Status:** Failed" >> $GITHUB_STEP_SUMMARY + echo "โŒ **Status:** Failed (No build created)" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + fi + + # Summary + echo "---" >> $GITHUB_STEP_SUMMARY + if [ "$ANDROID_RESULT" = "success" ] && [ "$IOS_RESULT" = "success" ]; then + echo "โœ… **Overall Result:** All tests passed" >> $GITHUB_STEP_SUMMARY + else + echo "โš ๏ธ **Overall Result:** Some tests failed" >> $GITHUB_STEP_SUMMARY fi - echo "๐Ÿ”— **[View Dashboard](https://app-automate.browserstack.com/dashboard/v2/builds/$IOS_BUILD_ID)**" >> $GITHUB_STEP_SUMMARY - echo "Build ID: \`$IOS_BUILD_ID\`" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - else - echo "โŒ **Status:** Failed (No build created)" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - fi - - # Summary - echo "---" >> $GITHUB_STEP_SUMMARY - if [ "$ANDROID_STATUS" = "success" ] && [ "$IOS_STATUS" = "success" ]; then - echo "โœ… **Overall Result:** All tests passed" >> $GITHUB_STEP_SUMMARY - else - echo "โš ๏ธ **Overall Result:** Some tests failed" >> $GITHUB_STEP_SUMMARY - fi diff --git a/.github/workflows/rust-tui-ci.yml b/.github/workflows/rust-tui-ci.yml index a32978e8e..de6e3a857 100644 --- a/.github/workflows/rust-tui-ci.yml +++ b/.github/workflows/rust-tui-ci.yml @@ -1,11 +1,6 @@ name: Rust TUI CI on: - push: - branches: [main] - paths: - - 'rust-tui/**' pull_request: - branches: [main] paths: - 'rust-tui/**' workflow_dispatch: @@ -124,7 +119,7 @@ jobs: - name: Build and run integration test working-directory: rust-tui env: - TASK_TO_FIND: ${{ steps.seed.outputs.document-title }} + DITTO_CLOUD_TASK_TITLE: ${{ steps.seed.outputs.document-title }} GITHUB_RUN_ID: ${{ github.run_id }} GITHUB_RUN_NUMBER: ${{ github.run_number }} run: | diff --git a/.github/workflows/swift-ci.yml b/.github/workflows/swift-ci.yml index 8bdb4532f..e1a2fba5d 100644 --- a/.github/workflows/swift-ci.yml +++ b/.github/workflows/swift-ci.yml @@ -1,16 +1,10 @@ -name: Swift +name: Swift CI on: pull_request: - branches: [main, 'sdk-*'] paths: - - 'swift/**' - - '.github/workflows/swift-ci.yml' - push: - branches: [main, 'sdk-*'] - paths: - - 'swift/**' - - '.github/workflows/swift-ci.yml' + - "swift/**" + - ".github/workflows/swift-ci.yml" workflow_dispatch: concurrency: @@ -22,91 +16,91 @@ jobs: name: Lint runs-on: macos-latest timeout-minutes: 10 - + steps: - - uses: actions/checkout@v4 - - - name: Setup Xcode - uses: maxim-lobanov/setup-xcode@v1 - with: - xcode-version: 'latest-stable' - - - name: Create .env file (test credentials for linting) - run: | - echo "DITTO_APP_ID=test_app_id" > .env - echo "DITTO_PLAYGROUND_TOKEN=test_token" >> .env - echo "DITTO_AUTH_URL=https://test.com" >> .env - echo "DITTO_WEBSOCKET_URL=wss://test.com" >> .env - - - name: Generate Env.swift (test) - working-directory: swift - run: | - chmod +x buildEnv.sh - ./buildEnv.sh ../.env Tasks/ - - - name: Install SwiftLint - run: | - if ! command -v swiftlint &> /dev/null; then - echo "Installing SwiftLint..." - brew install swiftlint - fi - swiftlint version - - - name: Run SwiftLint - working-directory: swift - run: | - echo "๐Ÿ” Running SwiftLint analysis..." - swiftlint lint --config .swiftlint.yml + - uses: actions/checkout@v4 + + - name: Setup Xcode + uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: "latest-stable" + + - name: Create .env file (test credentials for linting) + run: | + echo "DITTO_APP_ID=test_app_id" > .env + echo "DITTO_PLAYGROUND_TOKEN=test_token" >> .env + echo "DITTO_AUTH_URL=https://test.com" >> .env + echo "DITTO_WEBSOCKET_URL=wss://test.com" >> .env + + - name: Generate Env.swift (test) + working-directory: swift + run: | + chmod +x buildEnv.sh + ./buildEnv.sh ../.env Tasks/ + + - name: Install SwiftLint + run: | + if ! command -v swiftlint &> /dev/null; then + echo "Installing SwiftLint..." + brew install swiftlint + fi + swiftlint version + + - name: Run SwiftLint + working-directory: swift + run: | + echo "๐Ÿ” Running SwiftLint analysis..." + swiftlint lint --config .swiftlint.yml build-macos: name: Build macOS runs-on: macos-latest needs: lint timeout-minutes: 20 - + steps: - - uses: actions/checkout@v4 - - - name: Setup Xcode - uses: maxim-lobanov/setup-xcode@v1 - with: - xcode-version: 'latest-stable' - - - name: Create .env file (production credentials) - run: | - echo "DITTO_APP_ID=${{ secrets.DITTO_APP_ID }}" > .env - echo "DITTO_PLAYGROUND_TOKEN=${{ secrets.DITTO_PLAYGROUND_TOKEN }}" >> .env - echo "DITTO_AUTH_URL=${{ secrets.DITTO_AUTH_URL }}" >> .env - echo "DITTO_WEBSOCKET_URL=${{ secrets.DITTO_WEBSOCKET_URL }}" >> .env - - - name: Generate Env.swift (production) - working-directory: swift - run: | - chmod +x buildEnv.sh - ./buildEnv.sh ../.env Tasks/ - - - name: Resolve Package Dependencies - working-directory: swift - run: | - echo "๐Ÿ“ฆ Resolving Swift Package dependencies..." - xcodebuild -resolvePackageDependencies \ - -project Tasks.xcodeproj \ - -scheme Tasks - - - name: Build macOS App - working-directory: swift - run: | - echo "๐ŸŽ Building macOS app..." - xcodebuild build \ - -project Tasks.xcodeproj \ - -scheme Tasks \ - -configuration Debug \ - -destination 'platform=macOS' \ - -allowProvisioningUpdates \ - SWIFT_TREAT_WARNINGS_AS_ERRORS=NO \ - CODE_SIGN_IDENTITY="" \ - CODE_SIGNING_REQUIRED=NO \ - CODE_SIGNING_ALLOWED=NO + - uses: actions/checkout@v4 + + - name: Setup Xcode + uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: "latest-stable" + + - name: Create .env file (production credentials) + run: | + echo "DITTO_APP_ID=${{ secrets.DITTO_APP_ID }}" > .env + echo "DITTO_PLAYGROUND_TOKEN=${{ secrets.DITTO_PLAYGROUND_TOKEN }}" >> .env + echo "DITTO_AUTH_URL=${{ secrets.DITTO_AUTH_URL }}" >> .env + echo "DITTO_WEBSOCKET_URL=${{ secrets.DITTO_WEBSOCKET_URL }}" >> .env + + - name: Generate Env.swift (production) + working-directory: swift + run: | + chmod +x buildEnv.sh + ./buildEnv.sh ../.env Tasks/ + + - name: Resolve Package Dependencies + working-directory: swift + run: | + echo "๐Ÿ“ฆ Resolving Swift Package dependencies..." + xcodebuild -resolvePackageDependencies \ + -project Tasks.xcodeproj \ + -scheme Tasks + + - name: Build macOS App + working-directory: swift + run: | + echo "๐ŸŽ Building macOS app..." + xcodebuild build \ + -project Tasks.xcodeproj \ + -scheme Tasks \ + -configuration Debug \ + -destination 'platform=macOS' \ + -allowProvisioningUpdates \ + SWIFT_TREAT_WARNINGS_AS_ERRORS=NO \ + CODE_SIGN_IDENTITY="" \ + CODE_SIGNING_REQUIRED=NO \ + CODE_SIGNING_ALLOWED=NO build-ios: name: Build iOS @@ -115,432 +109,456 @@ jobs: timeout-minutes: 20 outputs: ios-build-success: ${{ steps.build-status.outputs.success }} - + steps: - - uses: actions/checkout@v4 - - - name: Setup Xcode - uses: maxim-lobanov/setup-xcode@v1 - with: - xcode-version: 'latest-stable' - - - name: Create .env file (production credentials) - run: | - echo "DITTO_APP_ID=${{ secrets.DITTO_APP_ID }}" > .env - echo "DITTO_PLAYGROUND_TOKEN=${{ secrets.DITTO_PLAYGROUND_TOKEN }}" >> .env - echo "DITTO_AUTH_URL=${{ secrets.DITTO_AUTH_URL }}" >> .env - echo "DITTO_WEBSOCKET_URL=${{ secrets.DITTO_WEBSOCKET_URL }}" >> .env - - - name: Generate Env.swift (production) - working-directory: swift - run: | - chmod +x buildEnv.sh - ./buildEnv.sh ../.env Tasks/ - - - name: Resolve Package Dependencies - working-directory: swift - run: | - echo "๐Ÿ“ฆ Resolving Swift Package dependencies..." - xcodebuild -resolvePackageDependencies \ - -project Tasks.xcodeproj \ - -scheme Tasks - - - name: Build iOS Simulator - working-directory: swift - run: | - echo "๐Ÿ”จ Building iOS app for simulator..." - xcodebuild build \ - -project Tasks.xcodeproj \ - -scheme Tasks \ - -configuration Debug \ - -sdk iphonesimulator \ - -destination 'generic/platform=iOS Simulator' \ - -allowProvisioningUpdates \ - SWIFT_TREAT_WARNINGS_AS_ERRORS=NO \ - CODE_SIGN_IDENTITY="" \ - CODE_SIGNING_REQUIRED=NO \ - CODE_SIGNING_ALLOWED=NO \ - ONLY_ACTIVE_ARCH=NO - - - name: Build iOS Device Archive and IPA (for BrowserStack) - working-directory: swift - run: | - echo "๐ŸŽ Building iOS device .ipa for BrowserStack..." - - # Build and archive iOS app for real device - xcodebuild -project Tasks.xcodeproj \ - -scheme Tasks \ - -configuration Debug \ - -destination 'generic/platform=iOS' \ - -archivePath build/Tasks.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 build/Tasks.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 zipped as .ipa - mkdir -p build/Payload - cp -R "$APP_BUNDLE_PATH" build/Payload/ - (cd build && zip -qry Tasks-unsigned.ipa Payload && rm -rf Payload) - - if [ -f "build/Tasks-unsigned.ipa" ]; then - echo "โœ… Unsigned .ipa created successfully" - ls -la build/Tasks-unsigned.ipa + - uses: actions/checkout@v4 + + - name: Setup Xcode + uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: "latest-stable" + + - name: Create .env file (production credentials) + run: | + echo "DITTO_APP_ID=${{ secrets.DITTO_APP_ID }}" > .env + echo "DITTO_PLAYGROUND_TOKEN=${{ secrets.DITTO_PLAYGROUND_TOKEN }}" >> .env + echo "DITTO_AUTH_URL=${{ secrets.DITTO_AUTH_URL }}" >> .env + echo "DITTO_WEBSOCKET_URL=${{ secrets.DITTO_WEBSOCKET_URL }}" >> .env + + - name: Generate Env.swift (production) + working-directory: swift + run: | + chmod +x buildEnv.sh + ./buildEnv.sh ../.env Tasks/ + + - name: Resolve Package Dependencies + working-directory: swift + run: | + echo "๐Ÿ“ฆ Resolving Swift Package dependencies..." + xcodebuild -resolvePackageDependencies \ + -project Tasks.xcodeproj \ + -scheme Tasks + + - name: Build iOS Simulator + working-directory: swift + run: | + echo "๐Ÿ”จ Building iOS app for simulator..." + xcodebuild build \ + -project Tasks.xcodeproj \ + -scheme Tasks \ + -configuration Debug \ + -sdk iphonesimulator \ + -destination 'generic/platform=iOS Simulator' \ + -allowProvisioningUpdates \ + SWIFT_TREAT_WARNINGS_AS_ERRORS=NO \ + CODE_SIGN_IDENTITY="" \ + CODE_SIGNING_REQUIRED=NO \ + CODE_SIGNING_ALLOWED=NO \ + ONLY_ACTIVE_ARCH=NO + + - name: Build iOS Device Archive and IPA (for BrowserStack) + working-directory: swift + run: | + echo "๐ŸŽ Building iOS device .ipa for BrowserStack..." + + # Build and archive iOS app for real device + xcodebuild -project Tasks.xcodeproj \ + -scheme Tasks \ + -configuration Debug \ + -destination 'generic/platform=iOS' \ + -archivePath build/Tasks.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 build/Tasks.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 zipped as .ipa + mkdir -p build/Payload + cp -R "$APP_BUNDLE_PATH" build/Payload/ + (cd build && zip -qry Tasks-unsigned.ipa Payload && rm -rf Payload) + + if [ -f "build/Tasks-unsigned.ipa" ]; then + echo "โœ… Unsigned .ipa created successfully" + ls -la build/Tasks-unsigned.ipa + else + echo "โŒ Failed to create .ipa file" + exit 1 + fi else - echo "โŒ Failed to create .ipa file" + echo "โŒ iOS app bundle not found in archive" 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_id }} - path: swift/build/Tasks-unsigned.ipa - retention-days: 1 - - - name: Set build status - id: build-status - run: | - echo "success=true" >> $GITHUB_OUTPUT + + - name: Upload iOS IPA Artifact + uses: actions/upload-artifact@v4 + with: + name: ios-ipa-${{ github.run_id }} + path: swift/build/Tasks-unsigned.ipa + retention-days: 1 + + - name: Set build status + id: build-status + run: | + echo "success=true" >> $GITHUB_OUTPUT browserstack: name: BrowserStack iOS Testing runs-on: macos-latest needs: [build-ios] if: needs.build-ios.outputs.ios-build-success == 'true' - timeout-minutes: 60 - + timeout-minutes: 150 + outputs: + build_id: ${{ steps.test.outputs.build_id }} + steps: - - uses: actions/checkout@v4 - - - name: Seed test task to Ditto Cloud - id: seed_task - uses: ./.github/actions/seed-ditto-document - with: - ditto-api-key: ${{ secrets.DITTO_API_KEY }} - ditto-api-url: ${{ secrets.DITTO_API_URL }} - app-name: 'swift' - - - name: Setup Xcode - uses: maxim-lobanov/setup-xcode@v1 - with: - xcode-version: 'latest-stable' - - - name: Create .env file (production credentials for BrowserStack API) - run: | - echo "DITTO_APP_ID=${{ secrets.DITTO_APP_ID }}" > .env - echo "DITTO_PLAYGROUND_TOKEN=${{ secrets.DITTO_PLAYGROUND_TOKEN }}" >> .env - echo "DITTO_AUTH_URL=${{ secrets.DITTO_AUTH_URL }}" >> .env - echo "DITTO_WEBSOCKET_URL=${{ secrets.DITTO_WEBSOCKET_URL }}" >> .env - - - name: Generate Env.swift (production for XCUITest build) - working-directory: swift - run: | - chmod +x buildEnv.sh - ./buildEnv.sh ../.env Tasks/ - - - name: Download iOS IPA Artifact - uses: actions/download-artifact@v4 - with: - name: ios-ipa-${{ github.run_id }} - path: ./ - - - name: Build XCUITest Bundle for BrowserStack - id: build_test_bundle - working-directory: swift - run: | - set -euo pipefail - - echo "๐Ÿงช build-for-testing (device) with GitHub run info injected..." - DERIVED="$PWD/build/DerivedData" - PRODUCTS_DIR="$DERIVED/Build/Products" - - # Inject GitHub run info as build settings for the test - xcodebuild build-for-testing \ - -project Tasks.xcodeproj \ - -scheme Tasks \ - -configuration Debug \ - -destination 'generic/platform=iOS' \ - -derivedDataPath "$DERIVED" \ - CODE_SIGNING_ALLOWED=NO \ - CODE_SIGNING_REQUIRED=NO \ - GITHUB_RUN_ID=${{ github.run_id }} \ - GITHUB_RUN_NUMBER=${{ github.run_number }} - - echo "๐Ÿ” Looking for Runner.app (device) and .xctestrunโ€ฆ" - # Runner.app must come from Debug-iphoneos (device), not simulator - RUNNER_APP=$(find "$PRODUCTS_DIR" -type d -path "*/Debug-iphoneos/*-Runner.app" -print -quit || true) - XCTESTRUN=$(find "$PRODUCTS_DIR" -maxdepth 1 -type f -name "*.xctestrun" -print -quit || true) - - if [[ -z "${RUNNER_APP:-}" || ! -d "$RUNNER_APP" ]]; then - echo "โŒ Runner.app not found under $PRODUCTS_DIR" - find "$PRODUCTS_DIR" -type d -name "*-Runner.app" || true - exit 1 - fi - - if [[ -z "${XCTESTRUN:-}" || ! -f "$XCTESTRUN" ]]; then - echo "โŒ .xctestrun not found at $PRODUCTS_DIR root" - ls -la "$PRODUCTS_DIR" || true - exit 1 - fi - - echo "โœ… Runner: $RUNNER_APP" - echo "โœ… xctestrun: $XCTESTRUN" - - # Create ZIP with BOTH files at ZIP ROOT (exactly as BrowserStack expects) - OUT_DIR="$PWD/../build" - mkdir -p "$OUT_DIR" - OUT_ZIP="$OUT_DIR/TasksUITests.zip" - rm -f "$OUT_ZIP" - - echo "๐Ÿ“ฆ Creating $OUT_ZIP with root: [$(basename "$RUNNER_APP")]" - echo "โ„น๏ธ BrowserStack error requested removing .xctestrun file - trying Runner.app only" - ( cd "$(dirname "$RUNNER_APP")" && zip -qry "$OUT_ZIP" "$(basename "$RUNNER_APP")" ) - - echo "๐Ÿ” ZIP contents:" - unzip -l "$OUT_ZIP" | sed -n '1,120p' - - echo "test_bundle_path=$OUT_ZIP" >> "$GITHUB_OUTPUT" - echo "โœ… XCUITest test-suite zip ready: $OUT_ZIP" - - - name: Set environment variables for tests - run: | - echo "GITHUB_TEST_DOC_ID=${{ steps.seed_task.outputs.document-title }}" >> $GITHUB_ENV - echo "GITHUB_TEST_DOC_TITLE=${{ steps.seed_task.outputs.document-title }}" >> $GITHUB_ENV - - - name: Upload App and Test Bundle to BrowserStack - id: upload - run: | - echo "๐Ÿ“ค Uploading iOS app and XCUITest bundle to BrowserStack..." - - IPA_FILE="$(pwd)/Tasks-unsigned.ipa" - TEST_BUNDLE="${{ steps.build_test_bundle.outputs.test_bundle_path }}" - - echo "๐Ÿ“ฑ App file: $IPA_FILE" - echo "๐Ÿงช Test bundle: $TEST_BUNDLE" - - # Validate both files exist - if [ ! -f "$IPA_FILE" ]; then - echo "โŒ IPA file not found: $IPA_FILE" - exit 1 - fi - - if [ ! -f "$TEST_BUNDLE" ]; then - echo "โŒ Test bundle not found: $TEST_BUNDLE" - exit 1 - fi - - # Upload app to BrowserStack XCUITest v2 API - echo "๐Ÿ“ฑ Uploading app..." - APP_UPLOAD_RESPONSE=$(curl --fail --silent --show-error -u "${{ secrets.BROWSERSTACK_USERNAME }}:${{ secrets.BROWSERSTACK_ACCESS_KEY }}" \ - -X POST "https://api-cloud.browserstack.com/app-automate/xcuitest/v2/app" \ - -F "file=@$IPA_FILE" \ - -F "custom_id=ditto-swift-app") - - echo "App upload response: $APP_UPLOAD_RESPONSE" - APP_URL=$(echo "$APP_UPLOAD_RESPONSE" | jq -r .app_url) - - if [ "$APP_URL" = "null" ] || [ -z "$APP_URL" ]; then - echo "โŒ Failed to upload app" - echo "Response: $APP_UPLOAD_RESPONSE" - exit 1 - fi - - # Upload test bundle to XCUITest v2 API - echo "๐Ÿงช Uploading test bundle..." - echo "Debug: Test bundle path: $TEST_BUNDLE" - ls -la "$TEST_BUNDLE" || echo "Test bundle file not found!" - - # Upload with better error handling to see 422 details - TEST_UPLOAD_RESPONSE=$(curl -s -w "\n%{http_code}" -u "${{ secrets.BROWSERSTACK_USERNAME }}:${{ secrets.BROWSERSTACK_ACCESS_KEY }}" \ - -X POST "https://api-cloud.browserstack.com/app-automate/xcuitest/v2/test-suite" \ - -F "file=@$TEST_BUNDLE") - - HTTP_CODE=$(echo "$TEST_UPLOAD_RESPONSE" | tail -n1) - BODY=$(echo "$TEST_UPLOAD_RESPONSE" | sed '$d') - - echo "Test upload HTTP $HTTP_CODE" - echo "Response body: $BODY" - - if [ "$HTTP_CODE" -ne 200 ] && [ "$HTTP_CODE" -ne 201 ]; then - echo "โŒ Upload failed"; exit 1 - fi - - # Prefer test_suite_url, fallback to test_url - TEST_URL=$(echo "$BODY" | jq -r '.test_suite_url // .test_url // empty') - - if [ -z "$TEST_URL" ]; then - echo "โŒ Failed to upload test bundle - no test suite URL in response" - echo "Response: $BODY" - exit 1 - fi - - echo "app_url=$APP_URL" >> $GITHUB_OUTPUT - echo "test_url=$TEST_URL" >> $GITHUB_OUTPUT - echo "โœ… App and test bundle uploaded successfully" - echo "๐Ÿ“ฑ App URL: $APP_URL" - echo "๐Ÿงช Test URL: $TEST_URL" - - - name: Execute XCUITest on BrowserStack - id: test - run: | - # Validate inputs - APP_URL="${{ steps.upload.outputs.app_url }}" - TEST_URL="${{ steps.upload.outputs.test_url }}" - - echo "App URL: $APP_URL" - echo "Test URL: $TEST_URL" - - if [ -z "$APP_URL" ] || [ "$APP_URL" = "null" ]; then - echo "Error: No valid app URL available" - exit 1 - fi - - if [ -z "$TEST_URL" ] || [ "$TEST_URL" = "null" ]; then - echo "โš ๏ธ No test bundle available - skipping automated testing" - echo "๐Ÿ“ฑ App is available for manual testing in BrowserStack dashboard" - echo "build_id=" >> $GITHUB_OUTPUT - exit 0 - fi - - # Create XCUITest execution request using v2 API with setEnvVariables - BUILD_RESPONSE=$(curl -u "${{ secrets.BROWSERSTACK_USERNAME }}:${{ secrets.BROWSERSTACK_ACCESS_KEY }}" \ - -X POST "https://api-cloud.browserstack.com/app-automate/xcuitest/v2/build" \ - -H "Content-Type: application/json" \ - -d "{ - \"app\": \"$APP_URL\", - \"testSuite\": \"$TEST_URL\", - \"devices\": [\"iPhone 15 Pro-17\"], - \"project\": \"Ditto Swift\", - \"buildName\": \"Build #${{ github.run_number }}\", - \"buildTag\": \"${{ github.ref_name }}\", - \"deviceLogs\": true, - \"video\": true, - \"networkLogs\": true, - \"setEnvVariables\": { - \"GITHUB_RUN_ID\": \"${{ github.run_id }}\", - \"GITHUB_RUN_NUMBER\": \"${{ github.run_number }}\", - \"GITHUB_TEST_DOC_TITLE\": \"${{ env.GITHUB_TEST_DOC_TITLE }}\" - } - }") - - echo "BrowserStack API Response:" - echo "$BUILD_RESPONSE" - - BUILD_ID=$(echo "$BUILD_RESPONSE" | jq -r .build_id) - - if [ "$BUILD_ID" = "null" ] || [ -z "$BUILD_ID" ]; then - echo "Error: Failed to create BrowserStack build" - echo "Response: $BUILD_RESPONSE" - exit 1 - fi - - echo "build_id=$BUILD_ID" >> $GITHUB_OUTPUT - echo "Build started with ID: $BUILD_ID" - - - name: Wait for XCUITest to complete - run: | - BUILD_ID="${{ steps.test.outputs.build_id }}" - - if [ "$BUILD_ID" = "null" ] || [ -z "$BUILD_ID" ]; then - echo "โš ๏ธ No BUILD_ID available - automated testing was skipped" - echo "๐Ÿ“ฑ App upload completed for manual testing" - exit 0 - fi - - MAX_WAIT_TIME=900 # 15 minutes - CHECK_INTERVAL=30 # Check every 30 seconds - ELAPSED=0 - - while [ $ELAPSED -lt $MAX_WAIT_TIME ]; do - BUILD_STATUS_RESPONSE=$(curl -s -u "${{ secrets.BROWSERSTACK_USERNAME }}:${{ secrets.BROWSERSTACK_ACCESS_KEY }}" \ - "https://api-cloud.browserstack.com/app-automate/xcuitest/v2/builds/$BUILD_ID") - - BUILD_STATUS=$(echo "$BUILD_STATUS_RESPONSE" | jq -r .status) - - if [ "$BUILD_STATUS" = "null" ] || [ -z "$BUILD_STATUS" ]; then - echo "Error getting build status. Response: $BUILD_STATUS_RESPONSE" - sleep $CHECK_INTERVAL - ELAPSED=$((ELAPSED + CHECK_INTERVAL)) - continue + - uses: actions/checkout@v4 + + - name: Setup Xcode + uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: "latest-stable" + + - name: Create .env file (production credentials for BrowserStack API) + run: | + echo "DITTO_APP_ID=${{ secrets.DITTO_APP_ID }}" > .env + echo "DITTO_PLAYGROUND_TOKEN=${{ secrets.DITTO_PLAYGROUND_TOKEN }}" >> .env + echo "DITTO_AUTH_URL=${{ secrets.DITTO_AUTH_URL }}" >> .env + echo "DITTO_WEBSOCKET_URL=${{ secrets.DITTO_WEBSOCKET_URL }}" >> .env + + - name: Generate Env.swift (production for XCUITest build) + working-directory: swift + run: | + chmod +x buildEnv.sh + ./buildEnv.sh ../.env Tasks/ + + - name: Download iOS IPA Artifact + uses: actions/download-artifact@v4 + with: + name: ios-ipa-${{ github.run_id }} + path: ./ + + - name: Build XCUITest Bundle for BrowserStack + id: build_test_bundle + working-directory: swift + run: | + set -euo pipefail + + echo "๐Ÿงช build-for-testing (device) with GitHub run info injected..." + DERIVED="$PWD/build/DerivedData" + PRODUCTS_DIR="$DERIVED/Build/Products" + + # Inject GitHub run info as build settings for the test + xcodebuild build-for-testing \ + -project Tasks.xcodeproj \ + -scheme Tasks \ + -configuration Debug \ + -destination 'generic/platform=iOS' \ + -derivedDataPath "$DERIVED" \ + CODE_SIGNING_ALLOWED=NO \ + CODE_SIGNING_REQUIRED=NO \ + GITHUB_RUN_ID=${{ github.run_id }} \ + GITHUB_RUN_NUMBER=${{ github.run_number }} + + echo "๐Ÿ” Looking for Runner.app (device) and .xctestrunโ€ฆ" + # Runner.app must come from Debug-iphoneos (device), not simulator + RUNNER_APP=$(find "$PRODUCTS_DIR" -type d -path "*/Debug-iphoneos/*-Runner.app" -print -quit || true) + XCTESTRUN=$(find "$PRODUCTS_DIR" -maxdepth 1 -type f -name "*.xctestrun" -print -quit || true) + + if [[ -z "${RUNNER_APP:-}" || ! -d "$RUNNER_APP" ]]; then + echo "โŒ Runner.app not found under $PRODUCTS_DIR" + find "$PRODUCTS_DIR" -type d -name "*-Runner.app" || true + exit 1 + fi + + if [[ -z "${XCTESTRUN:-}" || ! -f "$XCTESTRUN" ]]; then + echo "โŒ .xctestrun not found at $PRODUCTS_DIR root" + ls -la "$PRODUCTS_DIR" || true + exit 1 + fi + + echo "โœ… Runner: $RUNNER_APP" + echo "โœ… xctestrun: $XCTESTRUN" + + # Create ZIP with BOTH files at ZIP ROOT (exactly as BrowserStack expects) + OUT_DIR="$PWD/../build" + mkdir -p "$OUT_DIR" + OUT_ZIP="$OUT_DIR/TasksUITests.zip" + rm -f "$OUT_ZIP" + + echo "๐Ÿ“ฆ Creating $OUT_ZIP with root: [$(basename "$RUNNER_APP")]" + echo "โ„น๏ธ BrowserStack error requested removing .xctestrun file - trying Runner.app only" + ( cd "$(dirname "$RUNNER_APP")" && zip -qry "$OUT_ZIP" "$(basename "$RUNNER_APP")" ) + + echo "๐Ÿ” ZIP contents:" + unzip -l "$OUT_ZIP" | sed -n '1,120p' + + echo "test_bundle_path=$OUT_ZIP" >> "$GITHUB_OUTPUT" + echo "โœ… XCUITest test-suite zip ready: $OUT_ZIP" + + - name: Upload App and Test Bundle to BrowserStack + id: upload + run: | + echo "๐Ÿ“ค Uploading iOS app and XCUITest bundle to BrowserStack..." + + IPA_FILE="$(pwd)/Tasks-unsigned.ipa" + TEST_BUNDLE="${{ steps.build_test_bundle.outputs.test_bundle_path }}" + + echo "๐Ÿ“ฑ App file: $IPA_FILE" + echo "๐Ÿงช Test bundle: $TEST_BUNDLE" + + # Validate both files exist + if [ ! -f "$IPA_FILE" ]; then + echo "โŒ IPA file not found: $IPA_FILE" + exit 1 fi - - echo "Build status: $BUILD_STATUS (elapsed: ${ELAPSED}s)" - - if [ "$BUILD_STATUS" = "done" ] || [ "$BUILD_STATUS" = "failed" ] || [ "$BUILD_STATUS" = "error" ] || [ "$BUILD_STATUS" = "passed" ] || [ "$BUILD_STATUS" = "completed" ]; then - echo "Build completed with status: $BUILD_STATUS" - break + + if [ ! -f "$TEST_BUNDLE" ]; then + echo "โŒ Test bundle not found: $TEST_BUNDLE" + exit 1 + fi + + # Upload app to BrowserStack XCUITest v2 API + echo "๐Ÿ“ฑ Uploading app..." + APP_UPLOAD_RESPONSE=$(curl --fail --silent --show-error -u "${{ secrets.BROWSERSTACK_USERNAME }}:${{ secrets.BROWSERSTACK_ACCESS_KEY }}" \ + -X POST "https://api-cloud.browserstack.com/app-automate/xcuitest/v2/app" \ + -F "file=@$IPA_FILE" \ + -F "custom_id=ditto-swift-app") + + echo "App upload response: $APP_UPLOAD_RESPONSE" + APP_URL=$(echo "$APP_UPLOAD_RESPONSE" | yq eval -p=json .app_url) + + if [ "$APP_URL" = "null" ] || [ -z "$APP_URL" ]; then + echo "โŒ Failed to upload app" + echo "Response: $APP_UPLOAD_RESPONSE" + exit 1 + fi + + # Upload test bundle to XCUITest v2 API + echo "๐Ÿงช Uploading test bundle..." + echo "Debug: Test bundle path: $TEST_BUNDLE" + ls -la "$TEST_BUNDLE" || echo "Test bundle file not found!" + + # Upload with better error handling to see 422 details + TEST_UPLOAD_RESPONSE=$(curl -s -w "\n%{http_code}" -u "${{ secrets.BROWSERSTACK_USERNAME }}:${{ secrets.BROWSERSTACK_ACCESS_KEY }}" \ + -X POST "https://api-cloud.browserstack.com/app-automate/xcuitest/v2/test-suite" \ + -F "file=@$TEST_BUNDLE") + + HTTP_CODE=$(echo "$TEST_UPLOAD_RESPONSE" | tail -n1) + BODY=$(echo "$TEST_UPLOAD_RESPONSE" | sed '$d') + + echo "Test upload HTTP $HTTP_CODE" + echo "Response body: $BODY" + + if [ "$HTTP_CODE" -ne 200 ] && [ "$HTTP_CODE" -ne 201 ]; then + echo "โŒ Upload failed"; exit 1 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/xcuitest/v2/builds/$BUILD_ID") - - echo "Final build result:" - echo "$FINAL_RESULT" | jq . - - # Check final status - BUILD_STATUS=$(echo "$FINAL_RESULT" | jq -r .status) - if [ "$BUILD_STATUS" != "passed" ]; then - echo "Build failed with status: $BUILD_STATUS" - exit 1 - else - echo "All tests passed successfully!" - fi + + # Prefer test_suite_url, fallback to test_url + TEST_URL=$(echo "$BODY" | yq eval -p=json '.test_suite_url // .test_url // ""') + + if [ -z "$TEST_URL" ]; then + echo "โŒ Failed to upload test bundle - no test suite URL in response" + echo "Response: $BODY" + exit 1 + fi + + echo "app_url=$APP_URL" >> $GITHUB_OUTPUT + echo "test_url=$TEST_URL" >> $GITHUB_OUTPUT + echo "โœ… App and test bundle uploaded successfully" + echo "๐Ÿ“ฑ App URL: $APP_URL" + echo "๐Ÿงช Test URL: $TEST_URL" + + - name: Get BrowserStack build info + id: build-info + uses: ./.github/actions/generate-browserstack-names + with: + + - name: Seed and execute XCUITests on BrowserStack + 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}_swift_ci_test_${{ github.run_id }}_${{ github.run_number }}\", + \"title\": \"${INVERTED_TIMESTAMP}_swift_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}_swift_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 + + # Validate inputs + APP_URL="${{ steps.upload.outputs.app_url }}" + TEST_URL="${{ steps.upload.outputs.test_url }}" + + echo "App URL: $APP_URL" + echo "Test URL: $TEST_URL" + + if [ -z "$APP_URL" ] || [ "$APP_URL" = "null" ]; then + echo "Error: No valid app URL available" + exit 1 + fi + + if [ -z "$TEST_URL" ] || [ "$TEST_URL" = "null" ]; then + echo "โš ๏ธ No test bundle available - skipping automated testing" + echo "๐Ÿ“ฑ App is available for manual testing in BrowserStack dashboard" + exit 0 + fi + + # Execute tests + # Load devices from centralized config + DEVICES=$(yq eval -o=json -I=0 '.swift.devices' .github/browserstack-devices.yml) + + BUILD_RESPONSE=$(curl -u "${{ secrets.BROWSERSTACK_USERNAME }}:${{ secrets.BROWSERSTACK_ACCESS_KEY }}" \ + -X POST "https://api-cloud.browserstack.com/app-automate/xcuitest/v2/build" \ + -H "Content-Type: application/json" \ + -d "{ + \"app\": \"$APP_URL\", + \"testSuite\": \"$TEST_URL\", + \"devices\": $DEVICES, + \"project\": \"${{ steps.build-info.outputs.project-name }}\", + \"buildName\": \"${{ steps.build-info.outputs.build-name }}\", + \"buildTag\": \"${{ github.head_ref || github.ref_name }}\", + \"deviceLogs\": true, + \"video\": true, + \"networkLogs\": true, + \"setEnvVariables\": { + \"GITHUB_RUN_ID\": \"${{ github.run_id }}\", + \"GITHUB_RUN_NUMBER\": \"${{ github.run_number }}\", + \"DITTO_CLOUD_TASK_TITLE\": \"$TASK_TITLE\" + } + }") + + echo "BrowserStack API Response:" + echo "$BUILD_RESPONSE" + + BUILD_ID=$(echo "$BUILD_RESPONSE" | yq eval .build_id) + + 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" + echo "build_id=$BUILD_ID" >> "$GITHUB_OUTPUT" + + # Wait for XCUITest to complete + MAX_WAIT_TIME=1080 # 18 minutes + CHECK_INTERVAL=30 # Check every 30 seconds + ELAPSED=0 + + while [ $ELAPSED -lt $MAX_WAIT_TIME ]; do + BUILD_STATUS_RESPONSE=$(curl -s -u "${{ secrets.BROWSERSTACK_USERNAME }}:${{ secrets.BROWSERSTACK_ACCESS_KEY }}" \ + "https://api-cloud.browserstack.com/app-automate/xcuitest/v2/builds/$BUILD_ID") + + BUILD_STATUS=$(echo "$BUILD_STATUS_RESPONSE" | yq eval .status) + + if [ "$BUILD_STATUS" = "null" ] || [ -z "$BUILD_STATUS" ]; then + echo "Error getting build status. Response: $BUILD_STATUS_RESPONSE" + sleep $CHECK_INTERVAL + ELAPSED=$((ELAPSED + CHECK_INTERVAL)) + continue + fi + + echo "Build status: $BUILD_STATUS (elapsed: ${ELAPSED}s)" + + if [ "$BUILD_STATUS" = "done" ] || [ "$BUILD_STATUS" = "failed" ] || [ "$BUILD_STATUS" = "error" ] || [ "$BUILD_STATUS" = "passed" ] || [ "$BUILD_STATUS" = "completed" ]; then + echo "Build completed with status: $BUILD_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/xcuitest/v2/builds/$BUILD_ID") + + echo "Final build result:" + echo "$FINAL_RESULT" | jq . + + # Check final status + BUILD_STATUS=$(echo "$FINAL_RESULT" | yq eval .status) + if [ "$BUILD_STATUS" != "passed" ]; then + echo "Build failed with status: $BUILD_STATUS" + exit 1 + else + echo "All tests passed successfully!" + fi summary: name: CI Report runs-on: ubuntu-latest - needs: [browserstack] + needs: [lint, build-macos, build-ios, browserstack] if: always() steps: - - name: Report Results - run: | - echo "## ๐Ÿ“ฑ Swift CI" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - - # Overall status - if [[ "${{ needs.lint.result }}" == "success" && \ - "${{ needs.build-macos.result }}" == "success" && \ - "${{ needs.build-ios.result }}" == "success" && \ - "${{ needs.browserstack.result }}" == "success" ]]; then - echo "**Overall Status:** โœ… All checks passed" >> $GITHUB_STEP_SUMMARY - else - echo "**Overall Status:** โŒ Failed" >> $GITHUB_STEP_SUMMARY - fi - echo "" >> $GITHUB_STEP_SUMMARY - - echo "| Job | Status |" >> $GITHUB_STEP_SUMMARY - echo "|-----|--------|" >> $GITHUB_STEP_SUMMARY - echo "| Lint | ${{ needs.lint.result == 'success' && 'โœ… Passed' || 'โŒ Failed' }} |" >> $GITHUB_STEP_SUMMARY - echo "| Build macOS | ${{ needs.build-macos.result == 'success' && 'โœ… Passed' || 'โŒ Failed' }} |" >> $GITHUB_STEP_SUMMARY - echo "| Build iOS | ${{ needs.build-ios.result == 'success' && 'โœ… Passed' || 'โŒ Failed' }} |" >> $GITHUB_STEP_SUMMARY - echo "| BrowserStack Tests | ${{ needs.browserstack.result == 'success' && 'โœ… Passed' || (needs.browserstack.result == 'skipped' && 'โญ๏ธ Skipped') || 'โŒ Failed' }} |" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - - # BrowserStack link - if [[ "${{ needs.browserstack.result }}" != "skipped" ]]; then - echo "### BrowserStack Session" >> $GITHUB_STEP_SUMMARY + - name: Report Results + run: | + echo "## ๐Ÿ“ฑ Swift CI" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + + # Overall status + if [[ "${{ needs.lint.result }}" == "success" && \ + "${{ needs.build-macos.result }}" == "success" && \ + "${{ needs.build-ios.result }}" == "success" && \ + "${{ needs.browserstack.result }}" == "success" ]]; then + echo "**Overall Status:** โœ… All checks passed" >> $GITHUB_STEP_SUMMARY + else + echo "**Overall Status:** โŒ Failed" >> $GITHUB_STEP_SUMMARY + fi echo "" >> $GITHUB_STEP_SUMMARY - echo "๐ŸŽ [View Test Results](https://app-automate.browserstack.com/builds?project=Ditto+Swift&build=Build+%23${{ github.run_number }})" >> $GITHUB_STEP_SUMMARY + + echo "| Job | Status |" >> $GITHUB_STEP_SUMMARY + echo "|-----|--------|" >> $GITHUB_STEP_SUMMARY + echo "| Lint | ${{ needs.lint.result == 'success' && 'โœ… Passed' || 'โŒ Failed' }} |" >> $GITHUB_STEP_SUMMARY + echo "| Build macOS | ${{ needs.build-macos.result == 'success' && 'โœ… Passed' || 'โŒ Failed' }} |" >> $GITHUB_STEP_SUMMARY + echo "| Build iOS | ${{ needs.build-ios.result == 'success' && 'โœ… Passed' || 'โŒ Failed' }} |" >> $GITHUB_STEP_SUMMARY + echo "| BrowserStack Tests | ${{ needs.browserstack.result == 'success' && 'โœ… Passed' || (needs.browserstack.result == 'skipped' && 'โญ๏ธ Skipped') || 'โŒ Failed' }} |" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY - echo "**Tested Device:**" >> $GITHUB_STEP_SUMMARY - echo "- iPhone 15 Pro (iOS 17)" >> $GITHUB_STEP_SUMMARY - fi \ No newline at end of file + + # BrowserStack link + if [[ "${{ needs.browserstack.result }}" != "skipped" ]]; then + echo "### BrowserStack Session" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "๐ŸŽ [View Test Results](https://app-automate.browserstack.com/dashboard/v2/builds/${{ needs.browserstack.outputs.build_id }}/)" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "**Tested Device:**" >> $GITHUB_STEP_SUMMARY + echo "- iPhone 15 Pro (iOS 17)" >> $GITHUB_STEP_SUMMARY + fi diff --git a/.github/workflows/test-build-info-action.yml b/.github/workflows/test-build-info-action.yml new file mode 100644 index 000000000..0c557d8dd --- /dev/null +++ b/.github/workflows/test-build-info-action.yml @@ -0,0 +1,39 @@ +name: Test Build Info Action + +on: + workflow_dispatch: + +jobs: + test-build-info: + name: Test Build Info Generation + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Test generate names action + id: build-info + uses: ./.github/actions/generate-browserstack-names + with: + platform-suffix: ' (Test Platform)' + + - name: Display outputs + run: | + echo "## ๐Ÿงช Build Info Action Test Results" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "### Outputs:" >> $GITHUB_STEP_SUMMARY + echo "- **Project Name:** \`${{ steps.build-info.outputs.project-name }}\`" >> $GITHUB_STEP_SUMMARY + echo "- **Build Name:** \`${{ steps.build-info.outputs.build-name }}\`" >> $GITHUB_STEP_SUMMARY + echo "- **PR Number:** \`${{ steps.build-info.outputs.pr-number }}\`" >> $GITHUB_STEP_SUMMARY + echo "- **PR Title:** \`${{ steps.build-info.outputs.pr-title }}\`" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "### Context Info:" >> $GITHUB_STEP_SUMMARY + echo "- **Event Name:** \`${{ github.event_name }}\`" >> $GITHUB_STEP_SUMMARY + echo "- **Head Ref:** \`${{ github.head_ref }}\`" >> $GITHUB_STEP_SUMMARY + echo "- **Ref Name:** \`${{ github.ref_name }}\`" >> $GITHUB_STEP_SUMMARY + echo "- **Run Number:** \`${{ github.run_number }}\`" >> $GITHUB_STEP_SUMMARY + + echo "" + echo "โœ… If you see 'PR-XXX: ' in Build Name above, the action is working!" + echo "โŒ If you see 'Build-XXX', the PR fetch failed (expected for workflow_dispatch on branch)" diff --git a/android-cpp/QuickStartTasksCPP/appium-test/README.md b/android-cpp/QuickStartTasksCPP/appium-test/README.md index 121c8645d..3ffa772ee 100644 --- a/android-cpp/QuickStartTasksCPP/appium-test/README.md +++ b/android-cpp/QuickStartTasksCPP/appium-test/README.md @@ -8,7 +8,7 @@ End-to-end Appium tests that verify dynamically seeded tasks appear in the Andro ```bash npm install -g appium appium --port 4723 -export GITHUB_TEST_DOC_ID="Your Task Name" +export DITTO_CLOUD_TASK_TITLE="Your Task Name" ``` ### Run Test @@ -23,7 +23,7 @@ export GITHUB_TEST_DOC_ID="Your Task Name" export BROWSERSTACK_USERNAME="your-username" export BROWSERSTACK_ACCESS_KEY="your-access-key" export BROWSERSTACK_APP_URL="bs://your-app-id" -export GITHUB_TEST_DOC_ID="Task Name to Find" +export DITTO_CLOUD_TASK_TITLE="Task Name to Find" ``` ### Run Test diff --git a/android-cpp/QuickStartTasksCPP/appium-test/src/test/kotlin/AppiumE2ETest.kt b/android-cpp/QuickStartTasksCPP/appium-test/src/test/kotlin/AppiumE2ETest.kt index e698802b0..983e6a2af 100644 --- a/android-cpp/QuickStartTasksCPP/appium-test/src/test/kotlin/AppiumE2ETest.kt +++ b/android-cpp/QuickStartTasksCPP/appium-test/src/test/kotlin/AppiumE2ETest.kt @@ -16,12 +16,21 @@ class AppiumE2ETest { val options = UiAutomator2Options().apply { if (isBrowserStack) { + // Load device config from environment (set by workflow from browserstack-devices.yml) + val deviceString = System.getenv("BROWSERSTACK_DEVICE") + ?: throw IllegalStateException("BROWSERSTACK_DEVICE environment variable not set") + val deviceParts = deviceString.split("-") + val deviceName = deviceParts[0] + val platformVersion = deviceParts.getOrElse(1) { + throw IllegalStateException("Invalid BROWSERSTACK_DEVICE format: $deviceString (expected 'Device Name-Version')") + } + setPlatformName("Android") - setDeviceName("Google Pixel 7") - setPlatformVersion("13.0") + setDeviceName(deviceName) + setPlatformVersion(platformVersion) setCapability("app", System.getenv("BROWSERSTACK_APP_URL")) - setCapability("project", "Ditto SDK Android CPP") - setCapability("build", "Appium E2E Tests") + setCapability("project", System.getenv("BROWSERSTACK_PROJECT") ?: "quickstart - Android C++") + setCapability("build", System.getenv("BROWSERSTACK_BUILD") ?: "Local Build") setCapability("name", "Task Sync Verification") setCapability("automationName", "UiAutomator2") } else { @@ -54,8 +63,8 @@ class AppiumE2ETest { @Test fun testTaskFromEnvironmentVariableAppears() { - val testTaskName = System.getenv("GITHUB_TEST_DOC_ID") - ?: throw IllegalStateException("GITHUB_TEST_DOC_ID environment variable not set") + val testTaskName = System.getenv("DITTO_CLOUD_TASK_TITLE") + ?: throw IllegalStateException("DITTO_CLOUD_TASK_TITLE environment variable not set") try { Thread.sleep(3000) diff --git a/android-java/app/build.gradle.kts b/android-java/app/build.gradle.kts index 511146563..dadbd08dc 100644 --- a/android-java/app/build.gradle.kts +++ b/android-java/app/build.gradle.kts @@ -79,9 +79,9 @@ android { versionName = "1.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" - + // Pass environment variables to instrumentation tests - testInstrumentationRunnerArguments["github_test_doc_id"] = System.getenv("GITHUB_TEST_DOC_ID") ?: "" + testInstrumentationRunnerArguments["DITTO_CLOUD_TASK_TITLE"] = System.getenv("DITTO_CLOUD_TASK_TITLE") ?: "" } buildTypes { diff --git a/android-java/app/src/androidTest/java/com/example/dittotasks/ExampleInstrumentedTest.java b/android-java/app/src/androidTest/java/com/example/dittotasks/ExampleInstrumentedTest.java index c750e18d2..741fe1f8a 100644 --- a/android-java/app/src/androidTest/java/com/example/dittotasks/ExampleInstrumentedTest.java +++ b/android-java/app/src/androidTest/java/com/example/dittotasks/ExampleInstrumentedTest.java @@ -19,19 +19,19 @@ public class ExampleInstrumentedTest { @Test public void testGitHubTestDocumentSyncs() throws Exception { // Get environment variable with fallback options - String title = InstrumentationRegistry.getArguments().getString("github_test_doc_id"); - + String title = InstrumentationRegistry.getArguments().getString("DITTO_CLOUD_TASK_TITLE"); + // Try multiple fallback sources if (title == null || title.trim().isEmpty()) { - title = System.getProperty("GITHUB_TEST_DOC_ID"); + title = System.getProperty("DITTO_CLOUD_TASK_TITLE"); } if (title == null || title.trim().isEmpty()) { - title = System.getenv("GITHUB_TEST_DOC_ID"); + title = System.getenv("DITTO_CLOUD_TASK_TITLE"); } - + // No fallback - fail if seed is not set if (title == null || title.trim().isEmpty()) { - throw new AssertionError("Expected test title in 'github_test_doc_id' (or GITHUB_TEST_DOC_ID); none provided. Must be seeded by CI."); + throw new AssertionError("Expected test title in 'DITTO_CLOUD_TASK_TITLE' (or DITTO_CLOUD_TASK_TITLE); none provided. Must be seeded by CI."); } Log.i("DittoTest", "Testing with document title: " + title); @@ -39,20 +39,20 @@ public void testGitHubTestDocumentSyncs() throws Exception { // Launch activity manually with proper error handling Log.i("DittoTest", "Launching MainActivity..."); Intent intent = new Intent(InstrumentationRegistry.getInstrumentation().getTargetContext(), MainActivity.class); - + try (ActivityScenario scenario = ActivityScenario.launch(intent)) { Log.i("DittoTest", "Activity launched successfully"); - + // Wait for Ditto to initialize and sync data // Note: Using fixed delay as Espresso IdlingResource is complex for Ditto sync timing Log.i("DittoTest", "Waiting for activity and Ditto initialization..."); Thread.sleep(6000); // Allow time for Ditto sync and UI updates - + // Verify activity is still running scenario.onActivity(activity -> { Log.i("DittoTest", "Activity is running: " + activity.getClass().getSimpleName()); }); - + // Run the test logic performTestLogic(title); } catch (Exception e) { @@ -62,13 +62,13 @@ public void testGitHubTestDocumentSyncs() throws Exception { } private void performTestLogic(String title) throws InterruptedException { - + // Wait for RecyclerView to appear and be populated (with timeout) waitForRecyclerViewToLoad(7_000); - + // Verify the seeded document is visible at the top (no scrolling needed) Log.i("DittoTest", "๐Ÿ” Searching for document with title: '" + title + "'"); - + try { onView(allOf(withId(R.id.task_text), withText(title))) .check(ViewAssertions.matches(isDisplayed())); @@ -76,7 +76,7 @@ private void performTestLogic(String title) throws InterruptedException { } catch (Exception e) { Log.e("DittoTest", "โŒ Document NOT found with title: '" + title + "'"); Log.e("DittoTest", "Error: " + e.getMessage()); - + // Log what's actually visible for debugging try { Log.i("DittoTest", "๐Ÿ” Debugging: Checking what tasks are actually visible..."); @@ -86,10 +86,10 @@ private void performTestLogic(String title) throws InterruptedException { } catch (Exception recyclerError) { Log.e("DittoTest", "RecyclerView not found or displayed: " + recyclerError.getMessage()); } - + throw e; // Re-throw the original exception } - + // Keep screen visible for BrowserStack video verification // This delay is required for BrowserStack test recording to capture the successful state // before the test completes and the activity is destroyed @@ -107,7 +107,7 @@ private void waitForRecyclerViewToLoad(long timeoutMs) throws InterruptedExcepti // Check that RecyclerView is displayed and has some items onView(withId(R.id.task_list)) .check(ViewAssertions.matches(isDisplayed())); - + Log.i("DittoTest", "RecyclerView is displayed and ready"); return; // success } catch (Exception e) { diff --git a/android-kotlin/QuickStartTasks/app/src/androidTest/java/live/ditto/quickstart/tasks/TasksUITest.kt b/android-kotlin/QuickStartTasks/app/src/androidTest/java/live/ditto/quickstart/tasks/TasksUITest.kt index 528ca8477..80b74e28a 100644 --- a/android-kotlin/QuickStartTasks/app/src/androidTest/java/live/ditto/quickstart/tasks/TasksUITest.kt +++ b/android-kotlin/QuickStartTasks/app/src/androidTest/java/live/ditto/quickstart/tasks/TasksUITest.kt @@ -14,27 +14,27 @@ import org.junit.Assert.assertTrue */ @RunWith(AndroidJUnit4::class) class TasksUITest { - + @get:Rule val composeTestRule = createAndroidComposeRule() - + @Test fun testDocumentSyncAndVerification() { // Get test document title from BrowserStack instrumentationOptions, BuildConfig, or fallback val args = InstrumentationRegistry.getArguments() - val fromInstrumentation = args?.getString("github_test_doc_id") - val fromBuildConfig = try { - BuildConfig.TEST_DOCUMENT_TITLE - } catch (e: NoSuchFieldError) { - null - } catch (e: ExceptionInInitializerError) { - null + val fromInstrumentation = args?.getString("DITTO_CLOUD_TASK_TITLE") + val fromBuildConfig = try { + BuildConfig.TEST_DOCUMENT_TITLE + } catch (e: NoSuchFieldError) { + null + } catch (e: ExceptionInInitializerError) { + null } - + val testDocumentTitle = fromInstrumentation?.takeIf { it.isNotEmpty() } ?: fromBuildConfig?.takeIf { it.isNotEmpty() } - ?: throw IllegalStateException("No test document title provided. Expected via instrumentationOptions 'github_test_doc_id' or BuildConfig.TEST_DOCUMENT_TITLE") - + ?: throw IllegalStateException("No test document title provided. Expected via instrumentationOptions 'DITTO_CLOUD_TASK_TITLE' or BuildConfig.TEST_DOCUMENT_TITLE") + try { // Wait for app initialization and Ditto sync with intelligent polling composeTestRule.waitForIdle() @@ -44,14 +44,14 @@ class TasksUITest { }, timeoutMillis = 18000 // Wait up to 18 seconds for app init and Ditto sync ) - + // Final verification that document exists composeTestRule .onNode(hasText(testDocumentTitle)) .assertExists("Document with title '$testDocumentTitle' should exist in the task list") - + println("โœ… DOCUMENT FOUND: '$testDocumentTitle'") - + } catch (e: IllegalStateException) { if (e.message?.contains("No compose hierarchies found") == true) { // Local environment fallback - validate parameter passing works diff --git a/cpp-tui/taskscpp/tests/integration_test.cpp b/cpp-tui/taskscpp/tests/integration_test.cpp index a054fb788..a3610d570 100644 --- a/cpp-tui/taskscpp/tests/integration_test.cpp +++ b/cpp-tui/taskscpp/tests/integration_test.cpp @@ -28,44 +28,44 @@ using std::this_thread::sleep_for; int main() { try { cout << "C++ GitHub Seeded Document Test" << endl; - + // Get the exact document title that GitHub Actions seeded - const auto expected_title_env = getenv("GITHUB_TEST_DOC_TITLE"); + const auto expected_title_env = getenv("DITTO_CLOUD_TASK_TITLE"); if (!expected_title_env || string(expected_title_env).empty()) { - cout << "FAIL: Missing GITHUB_TEST_DOC_TITLE environment variable" << endl; + cout << "FAIL: Missing DITTO_CLOUD_TASK_TITLE environment variable" << endl; std::exit(EXIT_FAILURE); } - + string expected_title = string(expected_title_env); cout << "Looking for seeded document: '" << expected_title << "'" << endl; - + // Initialize TasksPeer and start sync cout << "Initializing Ditto sync..." << endl; auto peer = unique_ptr(new TasksPeer( DITTO_APP_ID, - DITTO_PLAYGROUND_TOKEN, + DITTO_PLAYGROUND_TOKEN, DITTO_WEBSOCKET_URL, DITTO_AUTH_URL, true, // enable_cloud_sync "/tmp/cpp_integration_test" )); - + peer->start_sync(); cout << "Sync started, polling for document..." << endl; - + // Wait for sync and search for the exact document const auto max_wait_seconds = 30; const auto poll_interval_ms = 1000; auto found = false; - + auto start_time = high_resolution_clock::now(); - + while (duration_cast(high_resolution_clock::now() - start_time).count() < max_wait_seconds && !found) { auto elapsed = duration_cast(high_resolution_clock::now() - start_time).count(); - + vector tasks = peer->get_tasks(); cout << "Checking " << tasks.size() << " synced tasks at " << elapsed << "s..." << endl; - + for (size_t i = 0; i < tasks.size(); i++) { const auto& task = tasks[i]; if (task.title == expected_title) { @@ -74,14 +74,14 @@ int main() { break; } } - + if (!found) { sleep_for(milliseconds(poll_interval_ms)); } } - + auto final_elapsed = duration_cast(high_resolution_clock::now() - start_time).count(); - + if (found) { cout << "PASS: GitHub Actions โ†’ Ditto Cloud โ†’ C++ SDK sync verified in " << final_elapsed << "s" << endl; return 0; @@ -89,7 +89,7 @@ int main() { cout << "FAIL: Document '" << expected_title << "' not found after " << final_elapsed << "s" << endl; std::exit(EXIT_FAILURE); } - + } catch (const exception& e) { cout << "FAIL: Test exception: " << e.what() << endl; std::exit(EXIT_FAILURE); diff --git a/dotnet-maui/UITests.Android/AndroidTaskSearchTests.cs b/dotnet-maui/UITests.Android/AndroidTaskSearchTests.cs index 5ecf40c9a..54cd2e3ca 100644 --- a/dotnet-maui/UITests.Android/AndroidTaskSearchTests.cs +++ b/dotnet-maui/UITests.Android/AndroidTaskSearchTests.cs @@ -17,13 +17,23 @@ protected override AppiumDriver CreateDriver() if (!string.IsNullOrEmpty(browserstackUsername) && !string.IsNullOrEmpty(browserstackAccessKey)) { - // BrowserStack capabilities - match android-cpp pattern exactly + // Load device config from environment (set by workflow from browserstack-devices.json) + var deviceString = Environment.GetEnvironmentVariable("BROWSERSTACK_DEVICE"); + if (string.IsNullOrEmpty(deviceString)) + { + throw new InvalidOperationException("BROWSERSTACK_DEVICE environment variable must be set"); + } + var deviceParts = deviceString.Split('-'); + var deviceName = deviceParts[0]; + var platformVersion = deviceParts.Length > 1 ? deviceParts[1] : throw new InvalidOperationException("Device string must include platform version (e.g., 'Google Pixel 7-13.0')"); + + // BrowserStack capabilities options.PlatformName = "Android"; options.AutomationName = "UiAutomator2"; - options.DeviceName = "Google Pixel 7"; - options.PlatformVersion = "13.0"; + options.DeviceName = deviceName; + options.PlatformVersion = platformVersion; options.App = browserstackApp ?? GetAppPath(); - options.AddAdditionalAppiumOption("project", "Ditto .NET MAUI"); + options.AddAdditionalAppiumOption("project", "QuickStart .NET MAUI"); options.AddAdditionalAppiumOption("build", Environment.GetEnvironmentVariable("BUILD_NAME") ?? "Local Tests"); options.AddAdditionalAppiumOption("name", "Android Task Search Tests"); diff --git a/dotnet-maui/UITests.Android/Program.cs b/dotnet-maui/UITests.Android/Program.cs index 06d8794c6..a70d634d8 100644 --- a/dotnet-maui/UITests.Android/Program.cs +++ b/dotnet-maui/UITests.Android/Program.cs @@ -1,7 +1,7 @@ using UITests.Android; // Log environment variables for debugging -var expectedTitle = Environment.GetEnvironmentVariable("EXPECTED_TASK_TITLE"); +var expectedTitle = Environment.GetEnvironmentVariable("DITTO_CLOUD_TASK_TITLE"); var buildName = Environment.GetEnvironmentVariable("BUILD_NAME"); if (!string.IsNullOrEmpty(expectedTitle)) Console.WriteLine($"Expected task: {expectedTitle}"); diff --git a/dotnet-maui/UITests.Shared/TaskSearchTests.cs b/dotnet-maui/UITests.Shared/TaskSearchTests.cs index 841d0545b..fb53c12d1 100644 --- a/dotnet-maui/UITests.Shared/TaskSearchTests.cs +++ b/dotnet-maui/UITests.Shared/TaskSearchTests.cs @@ -11,11 +11,11 @@ public abstract class TaskSearchTests : BaseTest [Test] public void CanFindTaskByTitle() { - var expectedTaskTitle = Environment.GetEnvironmentVariable("EXPECTED_TASK_TITLE"); + var expectedTaskTitle = Environment.GetEnvironmentVariable("DITTO_CLOUD_TASK_TITLE"); if (string.IsNullOrEmpty(expectedTaskTitle)) { - Assert.Fail("EXPECTED_TASK_TITLE environment variable is not set or empty"); + Assert.Fail("DITTO_CLOUD_TASK_TITLE environment variable is not set or empty"); return; } diff --git a/dotnet-maui/UITests.iOS/Program.cs b/dotnet-maui/UITests.iOS/Program.cs index 2487c1cba..d6be342cd 100644 --- a/dotnet-maui/UITests.iOS/Program.cs +++ b/dotnet-maui/UITests.iOS/Program.cs @@ -1,7 +1,7 @@ using UITests.iOS; // Log environment variables for debugging -var expectedTitle = Environment.GetEnvironmentVariable("EXPECTED_TASK_TITLE"); +var expectedTitle = Environment.GetEnvironmentVariable("DITTO_CLOUD_TASK_TITLE"); var buildName = Environment.GetEnvironmentVariable("BUILD_NAME"); if (!string.IsNullOrEmpty(expectedTitle)) Console.WriteLine($"Expected task: {expectedTitle}"); diff --git a/dotnet-maui/UITests.iOS/iOSTaskSearchTests.cs b/dotnet-maui/UITests.iOS/iOSTaskSearchTests.cs index d0c2f4cae..0343360d7 100644 --- a/dotnet-maui/UITests.iOS/iOSTaskSearchTests.cs +++ b/dotnet-maui/UITests.iOS/iOSTaskSearchTests.cs @@ -19,15 +19,26 @@ protected override AppiumDriver CreateDriver() if (!string.IsNullOrEmpty(browserstackUsername) && !string.IsNullOrEmpty(browserstackAccessKey)) { + // Load device config from environment (set by workflow from browserstack-devices.json) + var deviceString = Environment.GetEnvironmentVariable("BROWSERSTACK_DEVICE"); + if (string.IsNullOrEmpty(deviceString)) + { + throw new InvalidOperationException("BROWSERSTACK_DEVICE environment variable must be set"); + } + var deviceParts = deviceString.Split('-'); + var deviceName = deviceParts[0]; + var platformVersion = deviceParts.Length > 1 ? deviceParts[1] : throw new InvalidOperationException("Device string must include platform version (e.g., 'iPhone 15-17.0')"); + // BrowserStack capabilities for iOS options.PlatformName = "iOS"; options.AutomationName = "XCUITest"; - options.DeviceName = "iPhone 15"; - options.PlatformVersion = "17.0"; + options.DeviceName = deviceName; + options.PlatformVersion = platformVersion; options.App = browserstackApp ?? GetAppPath(); - options.AddAdditionalAppiumOption("project", "Ditto .NET MAUI"); + options.AddAdditionalAppiumOption("project", "QuickStart .NET MAUI"); options.AddAdditionalAppiumOption("build", Environment.GetEnvironmentVariable("BUILD_NAME") ?? "Local Tests"); options.AddAdditionalAppiumOption("name", "iOS Task Search Tests"); + options.AddAdditionalAppiumOption("autoGrantPermissions", true); var uri = new Uri($"https://{browserstackUsername}:{browserstackAccessKey}@hub-cloud.browserstack.com/wd/hub"); return new IOSDriver(uri, options); diff --git a/dotnet-tui/DittoDotNetTasksConsole.Tests/IntegrationTests.cs b/dotnet-tui/DittoDotNetTasksConsole.Tests/IntegrationTests.cs index 8dac98163..d71fc65a4 100644 --- a/dotnet-tui/DittoDotNetTasksConsole.Tests/IntegrationTests.cs +++ b/dotnet-tui/DittoDotNetTasksConsole.Tests/IntegrationTests.cs @@ -14,8 +14,8 @@ public class IntegrationTests public async Task TasksPeer_CanSyncAndRetrieveTasks() { var env = LoadEnvVariables(); - var taskToFind = Environment.GetEnvironmentVariable("TASK_TO_FIND") - ?? throw new InvalidOperationException("TASK_TO_FIND environment variable is required"); + var taskToFind = Environment.GetEnvironmentVariable("DITTO_CLOUD_TASK_TITLE") + ?? throw new InvalidOperationException("DITTO_CLOUD_TASK_TITLE environment variable is required"); using var peer = await TasksPeer.Create( env["DITTO_APP_ID"], diff --git a/dotnet-winforms/IntegrationTest/Program.cs b/dotnet-winforms/IntegrationTest/Program.cs index c786a4856..5d7bfded4 100644 --- a/dotnet-winforms/IntegrationTest/Program.cs +++ b/dotnet-winforms/IntegrationTest/Program.cs @@ -15,10 +15,10 @@ static async Task Main(string[] args) try { // Get the test document title from environment variable (set by CI) - var expectedTitle = Environment.GetEnvironmentVariable("GITHUB_TEST_DOC_TITLE"); + var expectedTitle = Environment.GetEnvironmentVariable("DITTO_CLOUD_TASK_TITLE"); if (string.IsNullOrEmpty(expectedTitle)) { - Console.WriteLine("โŒ FAIL: Missing GITHUB_TEST_DOC_TITLE environment variable"); + Console.WriteLine("โŒ FAIL: Missing DITTO_CLOUD_TASK_TITLE environment variable"); Console.WriteLine(" This test requires a document to be seeded by CI"); return 1; } diff --git a/flutter_app/integration_test/app_test.dart b/flutter_app/integration_test/app_test.dart index ee84d0dd2..ae42476ad 100644 --- a/flutter_app/integration_test/app_test.dart +++ b/flutter_app/integration_test/app_test.dart @@ -26,6 +26,13 @@ void main() { await tester.pumpAndSettle(const Duration(seconds: 2)); } + // Tap "Allow" button if local network permission dialog appears (iOS) + final allowButton = find.text('Allow'); + if (allowButton.evaluate().isNotEmpty) { + await tester.tap(allowButton); + await tester.pumpAndSettle(const Duration(seconds: 2)); + } + // Verify app title is present expect(find.text('Ditto Tasks'), findsOneWidget); diff --git a/java-spring/build.gradle.kts b/java-spring/build.gradle.kts index f80c878e2..d952a7e0e 100644 --- a/java-spring/build.gradle.kts +++ b/java-spring/build.gradle.kts @@ -72,4 +72,7 @@ dependencies { // Selenium WebDriver for visual browser testing testImplementation("org.seleniumhq.selenium:selenium-java:4.11.0") testImplementation("io.github.bonigarcia:webdrivermanager:5.9.2") + + // Jackson YAML for reading browserstack-devices.yml + testImplementation("com.fasterxml.jackson.dataformat:jackson-dataformat-yaml:2.15.2") } diff --git a/java-spring/src/test/java/com/ditto/example/spring/quickstart/TaskVisibilityIntegrationTest.java b/java-spring/src/test/java/com/ditto/example/spring/quickstart/TaskVisibilityIntegrationTest.java index 5b44e85c2..4245228de 100644 --- a/java-spring/src/test/java/com/ditto/example/spring/quickstart/TaskVisibilityIntegrationTest.java +++ b/java-spring/src/test/java/com/ditto/example/spring/quickstart/TaskVisibilityIntegrationTest.java @@ -1,5 +1,7 @@ package com.ditto.example.spring.quickstart; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; import io.github.bonigarcia.wdm.WebDriverManager; import org.junit.jupiter.api.*; import org.openqa.selenium.*; @@ -9,7 +11,10 @@ import org.openqa.selenium.support.ui.WebDriverWait; import org.openqa.selenium.support.ui.ExpectedConditions; +import java.io.File; +import java.io.IOException; import java.net.URL; +import java.nio.file.Paths; import java.time.Duration; import java.util.HashMap; import java.util.List; @@ -20,7 +25,7 @@ * Integration test for Task visibility using BrowserStack. * Tests the web UI by checking if tasks are properly visible. */ -@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) class TaskVisibilityIntegrationTest { private static WebDriver driver; @@ -49,33 +54,64 @@ static void setupWebDriver() { private static void setupBrowserStackDriver(String username, String accessKey) { try { ChromeOptions options = new ChromeOptions(); - + Map bsOptions = new HashMap<>(); + + // Read platform configuration from browserstack-devices.json + Map platformConfig = loadBrowserStackPlatformConfig(); + bsOptions.put("os", platformConfig.get("os")); + bsOptions.put("osVersion", platformConfig.get("osVersion")); + bsOptions.put("browserName", platformConfig.get("browserName")); + bsOptions.put("browserVersion", platformConfig.get("browserVersion")); bsOptions.put("sessionName", "Java Spring Task Visibility Test"); - + String bsLocal = firstNonEmpty(System.getProperty("BROWSERSTACK_LOCAL"), System.getenv("BROWSERSTACK_LOCAL")); if ("true".equals(bsLocal)) { bsOptions.put("local", "true"); } - + String buildName = System.getProperty("BROWSERSTACK_BUILD_NAME"); if (buildName != null && !buildName.isEmpty()) { bsOptions.put("buildName", buildName); } - + options.setCapability("bstack:options", bsOptions); - + RemoteWebDriver remote = new RemoteWebDriver( - new URL("https://" + username + ":" + accessKey + "@hub.browserstack.com/wd/hub"), + new URL("https://" + username + ":" + accessKey + "@hub.browserstack.com/wd/hub"), options ); driver = remote; - + } catch (Exception e) { throw new RuntimeException("Failed to initialize BrowserStack WebDriver: " + e.getMessage(), e); } } + private static Map loadBrowserStackPlatformConfig() { + try { + // Path to browserstack-devices.yml relative to project root + File configFile = Paths.get("..", ".github", "browserstack-devices.yml").toFile(); + if (!configFile.exists()) { + throw new RuntimeException("browserstack-devices.yml not found at: " + configFile.getAbsolutePath()); + } + + ObjectMapper mapper = new ObjectMapper(new com.fasterxml.jackson.dataformat.yaml.YAMLFactory()); + JsonNode root = mapper.readTree(configFile); + JsonNode javaPlatform = root.get("java-spring").get("platforms").get(0); + + Map config = new HashMap<>(); + config.put("os", javaPlatform.get("os").asText()); + config.put("osVersion", javaPlatform.get("osVersion").asText()); + config.put("browserName", javaPlatform.get("browserName").asText()); + config.put("browserVersion", javaPlatform.get("browserVersion").asText()); + + return config; + } catch (IOException e) { + throw new RuntimeException("Failed to load BrowserStack platform configuration: " + e.getMessage(), e); + } + } + private static void setupLocalChromeDriver() { try { WebDriverManager.chromedriver().setup(); @@ -86,7 +122,7 @@ private static void setupLocalChromeDriver() { options.addArguments("--no-sandbox"); options.addArguments("--disable-dev-shm-usage"); options.addArguments("--window-size=1200,800"); - + driver = new ChromeDriver(options); } catch (Exception e) { throw new RuntimeException("Failed to initialize local Chrome WebDriver: " + e.getMessage(), e); @@ -97,26 +133,26 @@ private static void setupLocalChromeDriver() { @Timeout(value = 10, unit = TimeUnit.MINUTES) void shouldPassWithExistingTask() { String envTitle = firstNonEmpty( - System.getenv("GITHUB_TEST_DOC_ID"), - System.getProperty("GITHUB_TEST_DOC_ID") + System.getenv("DITTO_CLOUD_TASK_TITLE"), + System.getProperty("DITTO_CLOUD_TASK_TITLE") ); - - Assertions.assertNotNull(envTitle, "GITHUB_TEST_DOC_ID must be provided for testing"); - + + Assertions.assertNotNull(envTitle, "DITTO_CLOUD_TASK_TITLE must be provided for testing"); + driver.get("http://localhost:8080"); wait.until(ExpectedConditions.titleContains("Ditto")); wait.until(ExpectedConditions.presenceOfElementLocated(By.cssSelector("input[placeholder*='Task']"))); - + // Enable sync if disabled enableSyncIfDisabled(); - + // Wait for tasks to load try { Thread.sleep(5000); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } - + boolean taskFound = isTaskVisibleOnPage(envTitle); Assertions.assertTrue(taskFound, "Task should be visible in the UI: " + envTitle); } @@ -126,14 +162,14 @@ private boolean isTaskVisibleOnPage(String taskTitle) { // Use safer approach: get all text elements and filter programmatically // This avoids XPath injection by not concatenating user input into XPath List allTextElements = driver.findElements(By.xpath("//*[text()]")); - + for (WebElement element : allTextElements) { String elementText = element.getText().trim(); if (elementText.equals(taskTitle)) { return true; } } - + return false; } catch (Exception e) { // Fallback to page source search (also safe from injection) @@ -146,7 +182,7 @@ private void enableSyncIfDisabled() { try { // Use CSS selector for more reliable element location List allElements = driver.findElements(By.cssSelector("*")); - + for (WebElement element : allElements) { String text = element.getText(); if (text.contains("Sync State: false")) { diff --git a/javascript-tui/package.json b/javascript-tui/package.json index f80ca9dee..4adca84ef 100644 --- a/javascript-tui/package.json +++ b/javascript-tui/package.json @@ -13,7 +13,7 @@ "dev": "babel --out-dir=dist --watch source", "test": "npm run test:format && npm run test:integration", "test:format": "prettier --check .", - "test:integration": "npm run build && GITHUB_TEST_DOC_TITLE=\"\" node tests/integration_test.js", + "test:integration": "npm run build && DITTO_CLOUD_TASK_TITLE=\"\" node tests/integration_test.js", "format": "prettier --write ." }, "files": [ diff --git a/javascript-tui/tests/integration_test.js b/javascript-tui/tests/integration_test.js index c058a6608..7c5298558 100644 --- a/javascript-tui/tests/integration_test.js +++ b/javascript-tui/tests/integration_test.js @@ -85,10 +85,10 @@ async function createDittoInstance() { async function runIntegrationTest() { try { - const expectedTitle = process.env.GITHUB_TEST_DOC_TITLE; + const expectedTitle = process.env.DITTO_CLOUD_TASK_TITLE; if (!expectedTitle || expectedTitle.trim() === '') { - throw new Error('Missing GITHUB_TEST_DOC_TITLE environment variable'); + throw new Error('Missing DITTO_CLOUD_TASK_TITLE environment variable'); } const ditto = await createDittoInstance(); diff --git a/kotlin-multiplatform/composeApp/build.gradle.kts b/kotlin-multiplatform/composeApp/build.gradle.kts index 025e2ddc6..7d2c6ad8a 100644 --- a/kotlin-multiplatform/composeApp/build.gradle.kts +++ b/kotlin-multiplatform/composeApp/build.gradle.kts @@ -67,7 +67,7 @@ kotlin { commonTest.dependencies { implementation(libs.kotlin.test) } - + val androidInstrumentedTest by getting { dependencies { implementation(libs.androidx.test.junit) @@ -115,7 +115,7 @@ kotlin { android { namespace = "com.ditto.quickstart" compileSdk = libs.versions.android.compileSdk.get().toInt() - + // Force consistent androidx.tracing version to resolve test dependency conflicts configurations.all { resolutionStrategy { @@ -129,11 +129,11 @@ android { targetSdk = libs.versions.android.targetSdk.get().toInt() versionCode = 1 versionName = "1.0" - + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" - + // Pass environment variables to instrumented tests - testInstrumentationRunnerArguments["github_test_doc_title"] = System.getenv("GITHUB_TEST_DOC_TITLE") ?: "" + testInstrumentationRunnerArguments["DITTO_CLOUD_TASK_TITLE"] = System.getenv("DITTO_CLOUD_TASK_TITLE") ?: "" } packaging { resources { @@ -167,7 +167,7 @@ detekt { dependencies { implementation(libs.androidx.material3.android) debugImplementation(compose.uiTooling) - + androidTestImplementation(libs.androidx.test.junit) androidTestImplementation(libs.androidx.test.runner) androidTestImplementation(libs.androidx.test.rules) diff --git a/kotlin-multiplatform/composeApp/src/androidInstrumentedTest/kotlin/com/ditto/quickstart/DittoSeededIdTest.kt b/kotlin-multiplatform/composeApp/src/androidInstrumentedTest/kotlin/com/ditto/quickstart/DittoSeededIdTest.kt index ff2639424..462a999a1 100644 --- a/kotlin-multiplatform/composeApp/src/androidInstrumentedTest/kotlin/com/ditto/quickstart/DittoSeededIdTest.kt +++ b/kotlin-multiplatform/composeApp/src/androidInstrumentedTest/kotlin/com/ditto/quickstart/DittoSeededIdTest.kt @@ -18,23 +18,23 @@ class DittoSeededIdTest { @Test fun testGitHubSeededDocumentSync() { val args = InstrumentationRegistry.getArguments() - val testDocumentTitle = args?.getString("github_test_doc_title") - ?: throw IllegalStateException("No test document title provided. Please provide it via the instrumentation argument 'github_test_doc_title'.") - + val testDocumentTitle = args?.getString("DITTO_CLOUD_TASK_TITLE") + ?: throw IllegalStateException("No test document title provided. Please provide it via the instrumentation argument 'DITTO_CLOUD_TASK_TITLE'.") + Thread.sleep(3000) - + // Handle system permission dialogs val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) for (i in 1..3) { try { val allowSelectors = listOf( UiSelector().text("Allow"), - UiSelector().text("ALLOW"), + UiSelector().text("ALLOW"), UiSelector().text("Allow only while using the app"), UiSelector().text("While using the app"), UiSelector().text("OK") ) - + var found = false for (selector in allowSelectors) { val allowButton = device.findObject(selector) @@ -45,13 +45,13 @@ class DittoSeededIdTest { break } } - + if (!found) break } catch (e: Exception) { break } } - + // Wait for document to appear composeTestRule.waitUntil( condition = { @@ -59,12 +59,12 @@ class DittoSeededIdTest { }, timeoutMillis = 15000 ) - + // Verify document exists composeTestRule .onNode(hasText(testDocumentTitle)) .assertExists("Document with title '$testDocumentTitle' should exist in the task list") - + // Allow time for video capture Thread.sleep(3000) } diff --git a/kotlin-multiplatform/iosApp/iosApp.xcodeproj/xcshareddata/xcschemes/iosApp.xcscheme b/kotlin-multiplatform/iosApp/iosApp.xcodeproj/xcshareddata/xcschemes/iosApp.xcscheme index 08cf594b7..764522017 100644 --- a/kotlin-multiplatform/iosApp/iosApp.xcodeproj/xcshareddata/xcschemes/iosApp.xcscheme +++ b/kotlin-multiplatform/iosApp/iosApp.xcodeproj/xcshareddata/xcschemes/iosApp.xcscheme @@ -31,8 +31,8 @@ shouldAutocreateTestPlan = "YES"> diff --git a/kotlin-multiplatform/iosApp/iosAppUITests/iosAppUITests.swift b/kotlin-multiplatform/iosApp/iosAppUITests/iosAppUITests.swift index a72527a60..f9a93daeb 100644 --- a/kotlin-multiplatform/iosApp/iosAppUITests/iosAppUITests.swift +++ b/kotlin-multiplatform/iosApp/iosAppUITests/iosAppUITests.swift @@ -4,8 +4,8 @@ final class iosAppUITests: XCTestCase { func testGitHubSeededDocumentSync() throws { // Get the exact document title that must be provided via environment variable - let expectedTitle = ProcessInfo.processInfo.environment["GITHUB_TEST_DOC_TITLE"] ?? "" - + let expectedTitle = ProcessInfo.processInfo.environment["DITTO_CLOUD_TASK_TITLE"] ?? "" + let app = XCUIApplication() app.launch() @@ -17,7 +17,7 @@ final class iosAppUITests: XCTestCase { let githubRunNumber = ProcessInfo.processInfo.environment["GITHUB_RUN_NUMBER"] ?? "" guard !expectedTitle.isEmpty else { - XCTFail("Missing GITHUB_TEST_DOC_TITLE - expected exact document title from GitHub Actions") + XCTFail("Missing DITTO_CLOUD_TASK_TITLE - expected exact document title from GitHub Actions") return } @@ -38,7 +38,7 @@ final class iosAppUITests: XCTestCase { // at the time of implementation. Using generic element detection instead of // accessibility identifiers or labels. Future improvement could investigate // proper accessibility support for Compose Multiplatform iOS targets. - + // For KMP Compose UI, look for LazyColumn items or similar // This may need adjustment based on your actual Compose UI structure let scrollViews = app.scrollViews @@ -73,22 +73,22 @@ final class iosAppUITests: XCTestCase { // Brief pause to allow text expansion usleep(200000) // 0.2 seconds } - + let label = textElement.label - + // Also check the value property in case it contains full text let value = textElement.value as? String ?? "" // Check both label and value for matches let textToCheck = [label, value].filter { !$0.isEmpty } - + for text in textToCheck { if text == expectedTitle { found = true break } } - + if found { break } } } diff --git a/react-native-expo/.maestro/flows/01-app-launch-and-seeded-tasks-android.yaml b/react-native-expo/.maestro/flows/01-app-launch-and-seeded-tasks-android.yaml index 86f7ca3de..31e7fe84f 100644 --- a/react-native-expo/.maestro/flows/01-app-launch-and-seeded-tasks-android.yaml +++ b/react-native-expo/.maestro/flows/01-app-launch-and-seeded-tasks-android.yaml @@ -3,7 +3,7 @@ tags: - "smoke" --- # Simple test: Launch app and verify specific task exists using testID -# Usage: MAESTRO_TASK_TO_FIND="Basic Test Task" maestro test 01-app-launch-and-seeded-tasks-android.yaml +# Usage: MAESTRO_DITTO_CLOUD_TASK_TITLE="Basic Test Task" maestro test 01-app-launch-and-seeded-tasks-android.yaml - launchApp: appId: com.anonymous.reactnativeexpo @@ -21,7 +21,7 @@ tags: # Debug: Try text selector first to see if task exists - scrollUntilVisible: element: - text: "${MAESTRO_TASK_TO_FIND}" + text: "${MAESTRO_DITTO_CLOUD_TASK_TITLE}" direction: DOWN timeout: 30000 speed: 40 @@ -29,8 +29,8 @@ tags: # Debug: Assert with text selector - assertVisible: - text: "${MAESTRO_TASK_TO_FIND}" + text: "${MAESTRO_DITTO_CLOUD_TASK_TITLE}" # Debug: Also try testID selector - assertVisible: - id: "${MAESTRO_TASK_TO_FIND}" \ No newline at end of file + id: "${MAESTRO_DITTO_CLOUD_TASK_TITLE}" \ No newline at end of file diff --git a/react-native-expo/.maestro/flows/01-app-launch-and-seeded-tasks-ios.yaml b/react-native-expo/.maestro/flows/01-app-launch-and-seeded-tasks-ios.yaml index 0e4f963cf..8314a3dc7 100644 --- a/react-native-expo/.maestro/flows/01-app-launch-and-seeded-tasks-ios.yaml +++ b/react-native-expo/.maestro/flows/01-app-launch-and-seeded-tasks-ios.yaml @@ -3,7 +3,7 @@ tags: - "smoke" --- # Simple test: Launch app and verify specific task exists using testID -# Usage: MAESTRO_TASK_TO_FIND="Basic Test Task" maestro test 01-app-launch-and-seeded-tasks-ios.yaml +# Usage: MAESTRO_DITTO_CLOUD_TASK_TITLE="Basic Test Task" maestro test 01-app-launch-and-seeded-tasks-ios.yaml - launchApp: appId: com.anonymous.reactnativeexpo @@ -28,7 +28,7 @@ tags: # Scroll until the task is visible using testID - scrollUntilVisible: element: - id: "${MAESTRO_TASK_TO_FIND}" + id: "${MAESTRO_DITTO_CLOUD_TASK_TITLE}" direction: DOWN timeout: 30000 speed: 40 @@ -36,4 +36,4 @@ tags: # Final assertion - task must be visible using id selector - assertVisible: - id: "${MAESTRO_TASK_TO_FIND}" \ No newline at end of file + id: "${MAESTRO_DITTO_CLOUD_TASK_TITLE}" \ No newline at end of file diff --git a/react-native/.maestro/flows/01-app-launch-and-seeded-tasks-android.yaml b/react-native/.maestro/flows/01-app-launch-and-seeded-tasks-android.yaml index ec6565c53..886dbe22f 100644 --- a/react-native/.maestro/flows/01-app-launch-and-seeded-tasks-android.yaml +++ b/react-native/.maestro/flows/01-app-launch-and-seeded-tasks-android.yaml @@ -3,7 +3,7 @@ tags: - "smoke" --- # Simple test: Launch app and verify specific task exists using testID -# Usage: MAESTRO_TASK_TO_FIND="Basic Test Task" maestro test 01-app-launch-and-seeded-tasks-android.yaml +# Usage: MAESTRO_DITTO_CLOUD_TASK_TITLE="Basic Test Task" maestro test 01-app-launch-and-seeded-tasks-android.yaml - launchApp: appId: com.dittoreactnativesampleapp @@ -21,7 +21,7 @@ tags: # Scroll until the task is visible using testID - scrollUntilVisible: element: - id: "${MAESTRO_TASK_TO_FIND}" + id: "${MAESTRO_DITTO_CLOUD_TASK_TITLE}" direction: DOWN timeout: 30000 speed: 40 @@ -29,4 +29,4 @@ tags: # Final assertion - task must be visible using id selector - assertVisible: - id: "${MAESTRO_TASK_TO_FIND}" \ No newline at end of file + id: "${MAESTRO_DITTO_CLOUD_TASK_TITLE}" \ No newline at end of file diff --git a/react-native/.maestro/flows/01-app-launch-and-seeded-tasks-ios.yaml b/react-native/.maestro/flows/01-app-launch-and-seeded-tasks-ios.yaml index 5cdd3d3a9..db411f264 100644 --- a/react-native/.maestro/flows/01-app-launch-and-seeded-tasks-ios.yaml +++ b/react-native/.maestro/flows/01-app-launch-and-seeded-tasks-ios.yaml @@ -3,7 +3,7 @@ tags: - "smoke" --- # Simple test: Launch app and verify specific task exists using testID -# Usage: MAESTRO_TASK_TO_FIND="Basic Test Task" maestro test 01-app-launch-and-seeded-tasks-ios.yaml +# Usage: MAESTRO_DITTO_CLOUD_TASK_TITLE="Basic Test Task" maestro test 01-app-launch-and-seeded-tasks-ios.yaml - launchApp: appId: org.reactjs.native.example.DittoReactNativeSampleApp @@ -28,7 +28,7 @@ tags: # Scroll until the task is visible using testID - scrollUntilVisible: element: - id: "${MAESTRO_TASK_TO_FIND}" + id: "${MAESTRO_DITTO_CLOUD_TASK_TITLE}" direction: DOWN timeout: 30000 speed: 40 @@ -36,4 +36,4 @@ tags: # Final assertion - task must be visible using id selector - assertVisible: - id: "${MAESTRO_TASK_TO_FIND}" \ No newline at end of file + id: "${MAESTRO_DITTO_CLOUD_TASK_TITLE}" \ No newline at end of file diff --git a/rust-tui/src/bin/integration_test.rs b/rust-tui/src/bin/integration_test.rs index 91f0faa04..48483be7e 100644 --- a/rust-tui/src/bin/integration_test.rs +++ b/rust-tui/src/bin/integration_test.rs @@ -24,7 +24,8 @@ async fn main() -> Result<()> { env::var("DITTO_WEBSOCKET_URL").unwrap_or_else(|_| "wss://cloud.ditto.live".to_string()); // Get task to find from environment - let task_to_find = env::var("TASK_TO_FIND").unwrap_or_else(|_| "Basic Test Task".to_string()); + let task_to_find = + env::var("DITTO_CLOUD_TASK_TITLE").context("DITTO_CLOUD_TASK_TITLE not found")?; println!("๐Ÿ” Looking for task: {}", task_to_find); diff --git a/swift/TasksUITests/TasksUITests.swift b/swift/TasksUITests/TasksUITests.swift index 8365e5dbf..2ce5bc349 100644 --- a/swift/TasksUITests/TasksUITests.swift +++ b/swift/TasksUITests/TasksUITests.swift @@ -4,8 +4,22 @@ final class TasksUITests: XCTestCase { func testDidFindOnlyGitHubSeededDocument() throws { let app = XCUIApplication() + + // Handle permission dialogs that may appear + addUIInterruptionMonitor(withDescription: "Local Network Permission") { alert in + let allowButton = alert.buttons["Allow"] + if allowButton.exists { + allowButton.tap() + return true + } + return false + } + app.launch() + // Wait a moment for permission dialog to appear and be handled + sleep(2) + XCTAssertTrue(app.wait(for: .runningForeground, timeout: 30), "App should launch successfully") @@ -14,7 +28,7 @@ final class TasksUITests: XCTestCase { let githubRunNumber = ProcessInfo.processInfo.environment["GITHUB_RUN_NUMBER"] ?? "" print("๐Ÿ” GitHub Run Info:") - print(" GITHUB_RUN_ID: '\(githubRunID)'") + print(" GITHUB_RUN_ID: '\(githubRunID)'") print(" GITHUB_RUN_NUMBER: '\(githubRunNumber)'") guard !githubRunID.isEmpty, !githubRunNumber.isEmpty else { @@ -23,13 +37,13 @@ final class TasksUITests: XCTestCase { } // Get the exact document title that GitHub Actions seeded - let expectedTitle = ProcessInfo.processInfo.environment["GITHUB_TEST_DOC_TITLE"] ?? "" - + let expectedTitle = ProcessInfo.processInfo.environment["DITTO_CLOUD_TASK_TITLE"] ?? "" + guard !expectedTitle.isEmpty else { - XCTFail("Missing GITHUB_TEST_DOC_TITLE - expected exact document title from GitHub Actions") + XCTFail("Missing DITTO_CLOUD_TASK_TITLE - expected exact document title from GitHub Actions") return } - + print("๐Ÿ” Looking for exact document with title: '\(expectedTitle)'") // Make maxWaitTime configurable via environment variable for BrowserStack environments @@ -46,7 +60,7 @@ final class TasksUITests: XCTestCase { while Date().timeIntervalSince(start) < maxWaitTime, !found { let elapsed = Date().timeIntervalSince(start) print("๐Ÿ“ฑ Search attempt at \(String(format: "%.1f", elapsed))s elapsed...") - + let cells = app.collectionViews.firstMatch.cells print("๐Ÿ“‹ Found \(cells.count) cells in collection view") @@ -56,14 +70,14 @@ final class TasksUITests: XCTestCase { print("๐Ÿ“„ Examining all documents:") for i in 0..