Create new buckets to run tests in independent processes (#1889) #5
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Mobile Integration Tests (LLM) | ||
|
Check failure on line 1 in .github/workflows/integration-mobile-test-qvac-lib-infer-llamacpp-llm.yml
|
||
| on: | ||
| workflow_call: | ||
| inputs: | ||
| ref: | ||
| description: "Git ref to checkout" | ||
| type: string | ||
| required: false | ||
| repository: | ||
| description: "Repository to checkout" | ||
| type: string | ||
| required: false | ||
| qvac_perf_runs: | ||
| description: "Override QVAC_PERF_RUNS (number of counted iterations per perf test). Empty = test default." | ||
| type: string | ||
| required: false | ||
| default: "" | ||
| qvac_perf_warmup_runs: | ||
| description: "Override QVAC_PERF_WARMUP_RUNS (number of warmup iterations per perf test). Empty = test default." | ||
| type: string | ||
| required: false | ||
| default: "" | ||
| workflow_dispatch: | ||
| inputs: | ||
| ref: | ||
| description: "Git ref (branch/tag/SHA) to test" | ||
| type: string | ||
| required: false | ||
| default: main | ||
| package: | ||
| description: "Full NPM package spec to test (default: @qvac/llm-llamacpp@latest)" | ||
| type: string | ||
| required: true | ||
| default: "@qvac/llm-llamacpp@latest" | ||
| qvac_perf_runs: | ||
| description: "Override QVAC_PERF_RUNS (number of counted iterations per perf test). Empty = test default." | ||
| type: string | ||
| required: false | ||
| default: "" | ||
| qvac_perf_warmup_runs: | ||
| description: "Override QVAC_PERF_WARMUP_RUNS (number of warmup iterations per perf test). Empty = test default." | ||
| type: string | ||
| required: false | ||
| default: "" | ||
| env: | ||
| NODE_VERSION: "lts/*" | ||
| ADDON_NAME: "@qvac/llm-llamacpp" | ||
| PREBUILD_ARTIFACT_PREFIX: "llama-cpp-" # Prefix for prebuild artifacts (empty string for generic patterns) | ||
| TEST_FRAMEWORK_REF: "main" # Branch/tag of qvac-test-addon-mobile framework | ||
| APP_BUNDLE_ID: "io.tether.test.qvac" # Bundle ID for the test app (same for all addons) | ||
| ADDON_WORKDIR: "addon/packages/qvac-lib-infer-llamacpp-llm" | ||
| jobs: | ||
| build-and-test: | ||
| name: Build ${{ matrix.platform }} and Run E2E Tests | ||
| runs-on: ${{ matrix.runner }} | ||
| environment: release | ||
| timeout-minutes: 120 | ||
| continue-on-error: true # Don't block PR merges if tests fail | ||
| permissions: | ||
| contents: read | ||
| packages: read | ||
| pull-requests: write # Allow commenting on PRs | ||
| id-token: write | ||
| strategy: | ||
| fail-fast: false | ||
| matrix: | ||
| include: | ||
| - platform: Android | ||
| os: ubuntu-24.04 | ||
| runner: ai-run-linux | ||
| - platform: iOS | ||
| os: macos-14 | ||
| runner: macos-14 | ||
| steps: | ||
| # Validate input data to prevent "Artifact Poisoning" | ||
| - name: Validate Dispatch Inputs | ||
| if: github.event_name == 'workflow_dispatch' && github.event.inputs.package | ||
| run: | | ||
| if [[ ! "${{ github.event.inputs.package }}" =~ ^@qvac/ ]]; then | ||
| echo "::error::Invalid package scope. Only @qvac/* is allowed." | ||
| exit 1 | ||
| fi | ||
| # Free up disk space on Ubuntu runner to prevent "No space left on device" errors | ||
| - name: Free up disk space | ||
| if: matrix.platform == 'Android' | ||
| run: | | ||
| echo "Disk space before cleanup:" | ||
| df -h | ||
| # Remove unnecessary software to free up disk space (|| true to handle self-hosted runners) | ||
| sudo rm -rf /usr/share/dotnet || true | ||
| sudo rm -rf /opt/ghc || true | ||
| sudo rm -rf /opt/hostedtoolcache/CodeQL || true | ||
| sudo rm -rf /opt/hostedtoolcache/go || true | ||
| sudo rm -rf /opt/hostedtoolcache/Python || true | ||
| sudo rm -rf /opt/hostedtoolcache/Ruby || true | ||
| sudo rm -rf /usr/local/lib/android/sdk/ndk || true | ||
| sudo rm -rf /usr/local/share/boost || true | ||
| sudo rm -rf /usr/share/swift || true | ||
| sudo docker image prune --all --force || true | ||
| # Clean APT cache | ||
| sudo apt-get clean || true | ||
| echo "Disk space after cleanup:" | ||
| df -h | ||
| - name: Checkout addon repository | ||
| uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # 6.0.2 | ||
| with: | ||
| repository: ${{ inputs.repository || github.repository }} | ||
| ref: ${{ inputs.ref || github.ref }} | ||
| token: ${{ secrets.PAT_TOKEN }} | ||
| path: addon | ||
| fetch-depth: 0 | ||
| - name: Checkout mobile test framework | ||
| uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # 6.0.2 | ||
| with: | ||
| repository: tetherto/qvac-test-addon-mobile | ||
| ref: ${{ env.TEST_FRAMEWORK_REF }} | ||
| token: ${{ secrets.PAT_TOKEN }} | ||
| path: test-framework | ||
| fetch-depth: 0 | ||
| - name: Setup Node.js | ||
| uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # 6.3.0 | ||
| with: | ||
| node-version: ${{ env.NODE_VERSION }} | ||
| - name: Install global dependencies | ||
| run: | | ||
| echo "Installing global dependencies..." | ||
| npm install -g @expo/cli@latest | ||
| - name: Download Android prebuilds (from artifacts) | ||
| if: matrix.platform == 'Android' && !inputs.package | ||
| uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # 8.0.1 | ||
| with: | ||
| path: ${{ env.ADDON_WORKDIR }}/prebuilds | ||
| pattern: ${{ env.PREBUILD_ARTIFACT_PREFIX }}android-* | ||
| merge-multiple: true | ||
| continue-on-error: true | ||
| - name: Download iOS prebuilds (from artifacts) | ||
| if: matrix.platform == 'iOS' && !inputs.package | ||
| uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # 8.0.1 | ||
| with: | ||
| path: ${{ env.ADDON_WORKDIR }}/prebuilds | ||
| pattern: ${{ env.PREBUILD_ARTIFACT_PREFIX }}ios-* | ||
| merge-multiple: true | ||
| continue-on-error: true | ||
| - name: Download prebuilds from package | ||
| if: inputs.package | ||
| working-directory: ${{ env.ADDON_WORKDIR }} | ||
| run: | | ||
| PACKAGE_SPEC="${{ inputs.package }}" | ||
| echo "📦 Downloading $PACKAGE_SPEC from npm for manual trigger..." | ||
| PACKAGE_NAME="${PACKAGE_SPEC%@*}" | ||
| if ! npm pack "$PACKAGE_SPEC" --ignore-scripts; then | ||
| echo "ERROR: Failed to download $PACKAGE_SPEC from npm" | ||
| echo "Please check that the package exists at https://www.npmjs.com/package/$PACKAGE_NAME" | ||
| exit 1 | ||
| fi | ||
| # Extract the tarball (pattern matches any addon name) | ||
| tar -xzf *.tgz | ||
| # Validate prebuilds directory exists | ||
| if [ ! -d "package/prebuilds" ]; then | ||
| echo "ERROR: No prebuilds directory found in package" | ||
| echo "The downloaded package may not contain prebuilt binaries" | ||
| exit 1 | ||
| fi | ||
| # Move prebuilds to expected location | ||
| mv package/prebuilds ./prebuilds | ||
| # Cleanup | ||
| rm -rf package *.tgz | ||
| echo "✅ Prebuilds downloaded from npm:" | ||
| ls -la prebuilds/ | ||
| - name: Verify and prepare prebuilds | ||
| working-directory: ${{ env.ADDON_WORKDIR }} | ||
| run: | | ||
| echo "Checking for prebuilds..." | ||
| echo "Current directory: $(pwd)" | ||
| if [ -d "prebuilds" ] && [ "$(ls -A prebuilds)" ]; then | ||
| echo "✅ Prebuilds found from artifacts:" | ||
| ls -la prebuilds/ | ||
| else | ||
| echo "❌ ERROR: No prebuilds found!" | ||
| echo " This workflow requires prebuilds to be available." | ||
| echo " Either:" | ||
| echo " 1. Run this workflow after prebuild job completes" | ||
| echo " 2. Or commit prebuilds to the repository" | ||
| exit 1 | ||
| fi | ||
| # Copy mobile prebuilds if needed | ||
| if npm run mobile:copy-prebuilds 2>/dev/null; then | ||
| echo "✅ Mobile prebuilds prepared" | ||
| else | ||
| echo "⚠️ mobile:copy-prebuilds script not available or failed" | ||
| fi | ||
| - name: Remove desktop prebuilds to save disk space | ||
| working-directory: ${{ env.ADDON_WORKDIR }} | ||
| run: | | ||
| echo "Removing desktop prebuilds to save disk space (keeping Android + iOS)..." | ||
| echo "Before cleanup:" | ||
| du -sh prebuilds/* 2>/dev/null || true | ||
| # Remove desktop prebuilds only (not needed for mobile tests) | ||
| rm -rf prebuilds/darwin-* prebuilds/win32-* prebuilds/linux-* 2>/dev/null || true | ||
| echo "After cleanup (Android + iOS only):" | ||
| du -sh prebuilds/* 2>/dev/null || true | ||
| df -h | ||
| - name: Verify test files exist | ||
| working-directory: ${{ env.ADDON_WORKDIR }} | ||
| run: | | ||
| echo "Verifying addon has mobile tests..." | ||
| if [ ! -d "test/mobile" ]; then | ||
| echo "❌ ERROR: test/mobile directory not found!" | ||
| echo "" | ||
| echo "This workflow requires the addon to have mobile tests at:" | ||
| echo " test/mobile/" | ||
| echo "" | ||
| echo "Please create this directory with your test files." | ||
| echo "See qvac-test-addon-mobile README for test file format." | ||
| exit 1 | ||
| fi | ||
| # Check for .cjs test files | ||
| CJS_COUNT=$(find test/mobile -name "*.cjs" -type f | wc -l) | ||
| if [ "$CJS_COUNT" -eq 0 ]; then | ||
| echo "❌ ERROR: No .cjs test files found in test/mobile!" | ||
| exit 1 | ||
| fi | ||
| echo "✅ Mobile test files found:" | ||
| ls -la test/mobile/*.cjs | ||
| # Check if testAssets exists | ||
| if [ -d "test/mobile/testAssets" ]; then | ||
| echo "" | ||
| echo "✅ Test assets found:" | ||
| ls -lah test/mobile/testAssets/ | ||
| else | ||
| echo "" | ||
| echo "ℹ️ No testAssets directory (this is optional)" | ||
| fi | ||
| - name: Install Ninja build tool | ||
| if: matrix.platform == 'iOS' | ||
| run: | | ||
| echo "📦 Installing Ninja build system..." | ||
| brew install ninja | ||
| ninja --version | ||
| echo "✅ Ninja installed successfully" | ||
| - name: Install addon dependencies | ||
| working-directory: ${{ env.ADDON_WORKDIR }} | ||
| run: | | ||
| echo "Installing addon dependencies..." | ||
| npm install --ignore-scripts | ||
| - name: Validate mobile tests are up-to-date | ||
| working-directory: ${{ env.ADDON_WORKDIR }} | ||
| run: npm run test:mobile:validate | ||
| - name: Pack addon | ||
| working-directory: ${{ env.ADDON_WORKDIR }} | ||
| run: | | ||
| echo "Packing addon..." | ||
| if npm run build:pack 2>/dev/null; then | ||
| echo "✅ Addon packed using build:pack script" | ||
| else | ||
| echo "📦 Using npm pack directly..." | ||
| mkdir -p dist | ||
| npm pack --pack-destination dist --ignore-scripts | ||
| fi | ||
| # Verify pack file exists | ||
| PACK_FILE=$(ls dist/*.tgz | head -1) | ||
| if [ -f "$PACK_FILE" ]; then | ||
| SIZE=$(du -h "$PACK_FILE" | cut -f1) | ||
| echo "✅ Pack file created: $PACK_FILE (Size: $SIZE)" | ||
| else | ||
| echo "❌ Pack file not found in dist/" | ||
| exit 1 | ||
| fi | ||
| - name: Setup test framework dependencies | ||
| working-directory: test-framework | ||
| run: | | ||
| echo "Setting up mobile test framework..." | ||
| npm install --ignore-scripts | ||
| echo "✅ Test framework dependencies installed" | ||
| - name: Build test app with addon | ||
| working-directory: test-framework | ||
| run: | | ||
| echo "Building test app with addon..." | ||
| echo "This will:" | ||
| echo " 1. Install the addon package" | ||
| echo " 2. Extract test code from addon's test/mobile/ directory" | ||
| echo " 3. Auto-detect and order test files by dependencies" | ||
| echo " 4. Generate backend.cjs with test functions" | ||
| echo " 5. Generate e2e tests for each test function" | ||
| echo " 6. Copy testAssets if available" | ||
| echo " 7. Bundle the app" | ||
| echo "" | ||
| ADDON_PATH="${{ github.workspace }}/${{ env.ADDON_WORKDIR }}" | ||
| npm run build "$ADDON_PATH" "$ADDON_PATH/test/mobile" | ||
| echo "" | ||
| echo "✅ Test app built successfully" | ||
| # Verify critical files were generated | ||
| if [ ! -f "backend/backend.cjs" ]; then | ||
| echo "❌ ERROR: backend/backend.cjs was not generated!" | ||
| exit 1 | ||
| fi | ||
| if [ ! -f "e2e/tests/app.test.js" ]; then | ||
| echo "❌ ERROR: e2e/tests/app.test.js was not generated!" | ||
| exit 1 | ||
| fi | ||
| if [ ! -f "backend/app.bundle" ]; then | ||
| echo "❌ ERROR: backend/app.bundle was not created!" | ||
| exit 1 | ||
| fi | ||
| echo "✅ All required files generated successfully" | ||
| # Show what tests were extracted | ||
| echo "" | ||
| echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" | ||
| echo "EXTRACTED TEST FUNCTIONS:" | ||
| echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" | ||
| if [ -f "app/testConfig.js" ]; then | ||
| cat app/testConfig.js | ||
| fi | ||
| echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" | ||
| - name: Display build summary | ||
| if: always() | ||
| working-directory: test-framework | ||
| run: | | ||
| echo "" | ||
| echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" | ||
| echo "📊 BUILD SUMMARY" | ||
| echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" | ||
| echo "" | ||
| echo "Platform: ${{ matrix.platform }}" | ||
| if [ "${{ github.event_name }}" == "workflow_dispatch" ]; then | ||
| echo "Package: ${{ inputs.package }}" | ||
| else | ||
| echo "Addon: ${{ env.ADDON_NAME }} (from PR artifacts)" | ||
| fi | ||
| echo "" | ||
| echo "Generated Files:" | ||
| echo " backend/backend.cjs: $([ -f backend/backend.cjs ] && echo '✅' || echo '❌')" | ||
| echo " backend/app.bundle: $([ -f backend/app.bundle ] && echo '✅' || echo '❌')" | ||
| echo " app/testConfig.js: $([ -f app/testConfig.js ] && echo '✅' || echo '❌')" | ||
| echo " app/assetManifest.js: $([ -f app/assetManifest.js ] && echo '✅' || echo '❌')" | ||
| echo " e2e/tests/app.test.js: $([ -f e2e/tests/app.test.js ] && echo '✅' || echo '❌')" | ||
| echo "" | ||
| echo "Test Assets:" | ||
| if [ -d "testAssets" ]; then | ||
| ASSET_COUNT=$(find testAssets -type f | wc -l) | ||
| echo " ✅ $ASSET_COUNT file(s) in testAssets/" | ||
| else | ||
| echo " ℹ️ No testAssets (optional)" | ||
| fi | ||
| echo "" | ||
| echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" | ||
| # Android-specific steps | ||
| - name: Set up JDK 17 | ||
| if: matrix.platform == 'Android' | ||
| uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # 5.2.0 | ||
| with: | ||
| java-version: 17 | ||
| distribution: temurin | ||
| - name: Setup Android SDK | ||
| if: matrix.platform == 'Android' | ||
| uses: android-actions/setup-android@9fc6c4e9069bf8d3d10b2204b1fb8f6ef7065407 # 3.2.2 | ||
| - name: Generate Android project | ||
| if: matrix.platform == 'Android' | ||
| working-directory: test-framework | ||
| run: | | ||
| echo "Generating Android project with Expo..." | ||
| npx expo prebuild --platform android --clean | ||
| - name: Build Android APK | ||
| if: matrix.platform == 'Android' | ||
| id: build_apk | ||
| working-directory: test-framework | ||
| run: | | ||
| echo "Building Android APK for Device Farm..." | ||
| export JAVA_HOME=$JAVA_HOME_17_X64 | ||
| # Bundle JavaScript | ||
| echo "Bundling JavaScript code..." | ||
| npm run bundle | ||
| if [ $? -ne 0 ]; then | ||
| echo "❌ Bundle failed" | ||
| exit 1 | ||
| fi | ||
| echo "✅ Bundle completed successfully" | ||
| # Build RELEASE APK (not debug) to ensure JS bundle is included | ||
| # Debug builds skip bundling by default and try to connect to Metro | ||
| # Release builds embed the JS bundle in the APK | ||
| cd android | ||
| echo "Building APK with Gradle (RELEASE with embedded JS bundle)..." | ||
| ./gradlew assembleRelease \ | ||
| -PreactNativeArchitectures=arm64-v8a \ | ||
| --no-daemon \ | ||
| --no-build-cache \ | ||
| --stacktrace | ||
| cd .. | ||
| # Find the APK (look for release) | ||
| APK_PATH=$(find android/app/build/outputs/apk -name "*.apk" | grep "release" | grep -v "unaligned" | head -1) | ||
| if [ -f "$APK_PATH" ]; then | ||
| # Convert to absolute path | ||
| APK_ABSOLUTE_PATH="${{ github.workspace }}/test-framework/$APK_PATH" | ||
| SIZE=$(du -h "$APK_PATH" | cut -f1) | ||
| echo "✅ APK built successfully: $APK_PATH (Size: $SIZE)" | ||
| echo "apk_path=$APK_ABSOLUTE_PATH" >> $GITHUB_OUTPUT | ||
| echo "app_type=ANDROID_APP" >> $GITHUB_OUTPUT | ||
| echo "app_name=test-app-${{ matrix.platform }}.apk" >> $GITHUB_OUTPUT | ||
| # Clean up build intermediates to free disk space | ||
| echo "Cleaning up build intermediates..." | ||
| rm -rf android/app/build/intermediates | ||
| rm -rf android/.gradle | ||
| df -h | ||
| else | ||
| echo "❌ APK file not found" | ||
| echo "Searching in android/app/build/outputs/apk:" | ||
| find android/app/build/outputs/apk -type f 2>/dev/null || echo "Directory not found" | ||
| exit 1 | ||
| fi | ||
| # iOS-specific steps | ||
| - name: Set up Xcode version | ||
| if: matrix.platform == 'iOS' | ||
| run: | | ||
| echo "Available Xcode versions:" | ||
| ls /Applications | grep Xcode || echo "No Xcode apps found" | ||
| echo "" | ||
| echo "Current Xcode (before switch):" | ||
| xcodebuild -version | ||
| # React Native requires Xcode >= 16.1 | ||
| # Use Xcode 16.1 (has iOS 18.1 SDK which is stable and pre-installed) | ||
| if [ -d "/Applications/Xcode_16.1.app" ]; then | ||
| echo "" | ||
| echo "✅ Switching to Xcode 16.1..." | ||
| sudo xcode-select -s /Applications/Xcode_16.1.app | ||
| elif [ -d "/Applications/Xcode_16.1.0.app" ]; then | ||
| echo "" | ||
| echo "✅ Switching to Xcode 16.1.0..." | ||
| sudo xcode-select -s /Applications/Xcode_16.1.0.app | ||
| elif [ -d "/Applications/Xcode_16.2.app" ]; then | ||
| echo "" | ||
| echo "⚠️ Using Xcode 16.2 (16.1 not found)..." | ||
| sudo xcode-select -s /Applications/Xcode_16.2.app | ||
| else | ||
| echo "" | ||
| echo "❌ ERROR: No suitable Xcode version found (need >= 16.1)" | ||
| exit 1 | ||
| fi | ||
| echo "" | ||
| echo "Current Xcode (after switch):" | ||
| xcodebuild -version | ||
| echo "" | ||
| echo "Available iOS SDKs:" | ||
| xcodebuild -showsdks | grep -i ios | ||
| - name: Install CocoaPods | ||
| if: matrix.platform == 'iOS' | ||
| run: | | ||
| sudo gem install cocoapods | ||
| pod --version | ||
| - name: Create Keychain and Import Certificate | ||
| if: matrix.platform == 'iOS' | ||
| env: | ||
| BUILD_CERTIFICATE_BASE64: ${{ secrets.TEST_APP_APPLE_DISTRIBUTION_CERTIFICATE }} | ||
| P12_PASSWORD: ${{ secrets.APPLE_P12_PASSWORD }} | ||
| BUILD_PROVISION_PROFILE_BASE64: ${{ secrets.TEST_APP_APPLE_PROVISIONING_PROFILE }} | ||
| KEYCHAIN_PASSWORD: ${{ secrets.APPLE_KEYCHAIN_PASSWORD }} | ||
| run: | | ||
| CERTIFICATE_PATH=$RUNNER_TEMP/build_certificate.p12 | ||
| PP_PATH=$RUNNER_TEMP/build_pp.mobileprovision | ||
| KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db | ||
| echo -n "$BUILD_CERTIFICATE_BASE64" | base64 --decode -o $CERTIFICATE_PATH | ||
| echo -n "$BUILD_PROVISION_PROFILE_BASE64" | base64 --decode -o $PP_PATH | ||
| security create-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH | ||
| security set-keychain-settings -lut 21600 $KEYCHAIN_PATH | ||
| security unlock-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH | ||
| security import $CERTIFICATE_PATH -P "$P12_PASSWORD" -A -t cert -f pkcs12 -k $KEYCHAIN_PATH | ||
| security set-key-partition-list -S apple-tool:,apple: -s -k "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH | ||
| security list-keychain -d user -s $KEYCHAIN_PATH | ||
| # Extract UUID first, then copy with UUID as filename | ||
| PP_UUID=$(/usr/libexec/PlistBuddy -c 'Print :UUID' /dev/stdin <<< $(security cms -D -i $PP_PATH)) | ||
| echo "PP_UUID=$PP_UUID" >> $GITHUB_ENV | ||
| echo "Provisioning Profile UUID: $PP_UUID" | ||
| # Copy provisioning profile with UUID as filename | ||
| mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles | ||
| cp $PP_PATH ~/Library/MobileDevice/Provisioning\ Profiles/$PP_UUID.mobileprovision | ||
| security find-identity -p codesigning -v | ||
| - name: Verify provisioning profile | ||
| if: matrix.platform == 'iOS' | ||
| run: | | ||
| echo "🔍 Verifying provisioning profile..." | ||
| echo "PP_UUID: $PP_UUID" | ||
| PP_FILE=~/Library/MobileDevice/Provisioning\ Profiles/$PP_UUID.mobileprovision | ||
| if [ ! -f "$PP_FILE" ]; then | ||
| echo "❌ Provisioning profile file not found at: $PP_FILE" | ||
| ls -la ~/Library/MobileDevice/Provisioning\ Profiles/ | ||
| exit 1 | ||
| fi | ||
| echo "📋 Provisioning Profile Details:" | ||
| security cms -D -i "$PP_FILE" > /tmp/profile.plist | ||
| PROFILE_NAME=$(/usr/libexec/PlistBuddy -c "Print :Name" /tmp/profile.plist 2>/dev/null || echo "Unknown") | ||
| PROFILE_BUNDLE_ID=$(/usr/libexec/PlistBuddy -c "Print :Entitlements:application-identifier" /tmp/profile.plist 2>/dev/null || echo "Unknown") | ||
| PROFILE_TEAM_ID=$(/usr/libexec/PlistBuddy -c "Print :Entitlements:com.apple.developer.team-identifier" /tmp/profile.plist 2>/dev/null || echo "Unknown") | ||
| # Detect profile type (Development, Ad Hoc, App Store, Enterprise) | ||
| HAS_DEVICES=$(/usr/libexec/PlistBuddy -c "Print :ProvisionedDevices" /tmp/profile.plist 2>/dev/null && echo "yes" || echo "no") | ||
| PROVISIONS_ALL=$(/usr/libexec/PlistBuddy -c "Print :ProvisionsAllDevices" /tmp/profile.plist 2>/dev/null || echo "false") | ||
| HAS_GET_TASK_ALLOW=$(/usr/libexec/PlistBuddy -c "Print :Entitlements:get-task-allow" /tmp/profile.plist 2>/dev/null || echo "false") | ||
| if [[ "$PROVISIONS_ALL" == "true" ]]; then | ||
| PROFILE_TYPE="Enterprise" | ||
| EXPORT_METHOD="enterprise" | ||
| elif [[ "$HAS_DEVICES" == "yes" && "$HAS_GET_TASK_ALLOW" == "true" ]]; then | ||
| PROFILE_TYPE="Development" | ||
| EXPORT_METHOD="development" | ||
| elif [[ "$HAS_DEVICES" == "yes" && "$HAS_GET_TASK_ALLOW" == "false" ]]; then | ||
| PROFILE_TYPE="Ad Hoc" | ||
| EXPORT_METHOD="ad-hoc" | ||
| else | ||
| PROFILE_TYPE="App Store" | ||
| EXPORT_METHOD="app-store" | ||
| fi | ||
| echo " Name: $PROFILE_NAME" | ||
| echo " Type: $PROFILE_TYPE" | ||
| echo " Export Method: $EXPORT_METHOD" | ||
| echo " Application ID: $PROFILE_BUNDLE_ID" | ||
| echo " Team ID: $PROFILE_TEAM_ID" | ||
| echo " Expected Bundle ID: ${{ env.APP_BUNDLE_ID }}" | ||
| # Save export method for next step | ||
| echo "EXPORT_METHOD=$EXPORT_METHOD" >> $GITHUB_ENV | ||
| # Extract just the bundle ID part (remove team prefix) | ||
| BUNDLE_ID_ONLY=$(echo "$PROFILE_BUNDLE_ID" | sed 's/^[^.]*\.//') | ||
| if [[ "$BUNDLE_ID_ONLY" != "${{ env.APP_BUNDLE_ID }}" ]]; then | ||
| echo "" | ||
| echo "❌ ERROR: Provisioning profile bundle ID mismatch!" | ||
| echo " Profile has: $BUNDLE_ID_ONLY" | ||
| echo " Expected: ${{ env.APP_BUNDLE_ID }}" | ||
| echo "" | ||
| echo "The provisioning profile was created for a different bundle identifier." | ||
| echo "Please create a new provisioning profile for: ${{ env.APP_BUNDLE_ID }}" | ||
| exit 1 | ||
| fi | ||
| echo "✅ Provisioning profile matches expected bundle ID" | ||
| - name: Generate iOS project | ||
| if: matrix.platform == 'iOS' | ||
| working-directory: test-framework | ||
| run: | | ||
| echo "Generating iOS project with Expo..." | ||
| npx expo prebuild --platform ios --clean | ||
| - name: Install iOS dependencies | ||
| if: matrix.platform == 'iOS' | ||
| working-directory: test-framework/ios | ||
| run: | | ||
| echo "Installing CocoaPods dependencies..." | ||
| pod install --repo-update | ||
| - name: Build and Archive iOS App | ||
| if: matrix.platform == 'iOS' | ||
| id: build_ios | ||
| working-directory: test-framework | ||
| run: | | ||
| echo "Building iOS app for Device Farm..." | ||
| # Bundle JavaScript first | ||
| echo "Bundling JavaScript code..." | ||
| npm run bundle | ||
| if [ $? -ne 0 ]; then | ||
| echo "❌ Bundle failed" | ||
| exit 1 | ||
| fi | ||
| echo "✅ Bundle completed successfully" | ||
| # Get scheme name | ||
| cd ios | ||
| SCHEME_NAME=$(xcodebuild -list | grep -A 1 "Schemes:" | grep -v "Schemes:" | head -1 | xargs) | ||
| echo "Detected scheme: $SCHEME_NAME" | ||
| # Debug: Check bundle identifier in project | ||
| echo "🔍 Checking project configuration..." | ||
| BUNDLE_ID=$(xcodebuild -showBuildSettings -workspace $SCHEME_NAME.xcworkspace -scheme "$SCHEME_NAME" -configuration Release -destination "generic/platform=iOS" 2>/dev/null | grep PRODUCT_BUNDLE_IDENTIFIER | head -1 | awk '{print $3}') | ||
| echo "Bundle Identifier in project: $BUNDLE_ID" | ||
| if [[ "$BUNDLE_ID" != "${{ env.APP_BUNDLE_ID }}" ]]; then | ||
| echo "⚠️ Warning: Bundle ID mismatch in Xcode project!" | ||
| echo " Expected: ${{ env.APP_BUNDLE_ID }}" | ||
| echo " Found: $BUNDLE_ID" | ||
| fi | ||
| # Debug: Check provisioning profile | ||
| echo "🔍 Provisioning profile UUID: $PP_UUID" | ||
| security cms -D -i ~/Library/MobileDevice/Provisioning\ Profiles/$PP_UUID.mobileprovision | grep -A 5 "application-identifier\|Name\|TeamIdentifier" | head -20 || echo "Could not read profile details" | ||
| # Archive for iOS device | ||
| xcodebuild -workspace $SCHEME_NAME.xcworkspace \ | ||
| -scheme "$SCHEME_NAME" \ | ||
| -sdk iphoneos \ | ||
| -configuration Release \ | ||
| -destination "generic/platform=iOS" \ | ||
| -archivePath $RUNNER_TEMP/$SCHEME_NAME.xcarchive \ | ||
| CODE_SIGN_STYLE=Manual \ | ||
| PROVISIONING_PROFILE_SPECIFIER="$PP_UUID" \ | ||
| CODE_SIGN_IDENTITY="Apple Distribution" \ | ||
| DEVELOPMENT_TEAM="${{ secrets.APPLE_TEAM_ID }}" \ | ||
| clean archive | ||
| - name: Export IPA | ||
| if: matrix.platform == 'iOS' | ||
| id: export_ipa | ||
| working-directory: test-framework/ios | ||
| run: | | ||
| SCHEME_NAME=$(xcodebuild -list | grep -A 1 "Schemes:" | grep -v "Schemes:" | head -1 | xargs) | ||
| # Create export options using auto-detected export method | ||
| # The EXPORT_METHOD was determined in the "Verify provisioning profile" step | ||
| echo "📦 Using export method: $EXPORT_METHOD" | ||
| EXPORT_OPTS_PATH=$RUNNER_TEMP/ExportOptions.plist | ||
| cat > $EXPORT_OPTS_PATH << EOF | ||
| <?xml version="1.0" encoding="UTF-8"?> | ||
| <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> | ||
| <plist version="1.0"> | ||
| <dict> | ||
| <key>method</key> | ||
| <string>$EXPORT_METHOD</string> | ||
| <key>teamID</key> | ||
| <string>${{ secrets.APPLE_TEAM_ID }}</string> | ||
| <key>signingStyle</key> | ||
| <string>manual</string> | ||
| <key>provisioningProfiles</key> | ||
| <dict> | ||
| <key>${{ env.APP_BUNDLE_ID }}</key> | ||
| <string>$PP_UUID</string> | ||
| </dict> | ||
| </dict> | ||
| </plist> | ||
| EOF | ||
| echo "📋 Export options:" | ||
| cat $EXPORT_OPTS_PATH | ||
| xcodebuild -exportArchive \ | ||
| -archivePath $RUNNER_TEMP/$SCHEME_NAME.xcarchive \ | ||
| -exportOptionsPlist $EXPORT_OPTS_PATH \ | ||
| -exportPath $RUNNER_TEMP/build | ||
| IPA_FILE=$(find $RUNNER_TEMP/build -name "*.ipa" | head -1) | ||
| if [ -f "$IPA_FILE" ]; then | ||
| echo "✅ IPA exported: $IPA_FILE" | ||
| echo "apk_path=$IPA_FILE" >> $GITHUB_OUTPUT | ||
| echo "app_type=IOS_APP" >> $GITHUB_OUTPUT | ||
| echo "app_name=test-app-${{ matrix.platform }}.ipa" >> $GITHUB_OUTPUT | ||
| else | ||
| echo "❌ IPA file not found" | ||
| exit 1 | ||
| fi | ||
| - name: Configure AWS credentials via OIDC | ||
| uses: aws-actions/configure-aws-credentials@8df5847569e6427dd6c4fb1cf565c83acfa8afa7 # 6.0.0 | ||
| with: | ||
| role-to-assume: ${{ secrets.AWS_OIDC_ROLE_ARN }} | ||
| aws-region: us-west-2 | ||
| role-session-name: device-farm-upload | ||
| role-duration-seconds: 7200 # 2hrs for device farm tests | ||
| - name: Upload App to Device Farm | ||
| id: upload_app | ||
| run: | | ||
| if [ "${{ matrix.platform }}" == "Android" ]; then | ||
| APP_PATH="${{ steps.build_apk.outputs.apk_path }}" | ||
| APP_TYPE="${{ steps.build_apk.outputs.app_type }}" | ||
| APP_NAME="${{ steps.build_apk.outputs.app_name }}" | ||
| else | ||
| APP_PATH="${{ steps.export_ipa.outputs.apk_path }}" | ||
| APP_TYPE="${{ steps.export_ipa.outputs.app_type }}" | ||
| APP_NAME="${{ steps.export_ipa.outputs.app_name }}" | ||
| fi | ||
| echo "📤 Uploading app to AWS Device Farm..." | ||
| UPLOAD_RESPONSE=$(aws devicefarm create-upload \ | ||
| --project-arn "${{ secrets.LLM_AWS_DEVICE_FARM_PROJECT_ARN }}" \ | ||
| --name "$APP_NAME" \ | ||
| --type "$APP_TYPE" \ | ||
| --output json) | ||
| if [ $? -ne 0 ]; then | ||
| echo "❌ Error creating upload in Device Farm" | ||
| echo "Response: $UPLOAD_RESPONSE" | ||
| exit 1 | ||
| fi | ||
| APP_UPLOAD_URL=$(echo $UPLOAD_RESPONSE | jq -r '.upload.url') | ||
| APP_UPLOAD_ARN=$(echo $UPLOAD_RESPONSE | jq -r '.upload.arn') | ||
| echo "app_upload_arn=$APP_UPLOAD_ARN" >> $GITHUB_OUTPUT | ||
| echo "App upload ARN: $APP_UPLOAD_ARN" | ||
| echo "Uploading app file: $APP_PATH" | ||
| curl -T "$APP_PATH" "$APP_UPLOAD_URL" | ||
| if [ $? -ne 0 ]; then | ||
| echo "❌ Error uploading app file using curl" | ||
| exit 1 | ||
| fi | ||
| # Wait for processing | ||
| echo "⏳ Waiting for upload to be processed..." | ||
| MAX_ATTEMPTS=30 | ||
| ATTEMPT=1 | ||
| while [ $ATTEMPT -le $MAX_ATTEMPTS ]; do | ||
| STATUS=$(aws devicefarm get-upload --arn "$APP_UPLOAD_ARN" --query "upload.status" --output text) | ||
| echo "Status (attempt $ATTEMPT/$MAX_ATTEMPTS): $STATUS" | ||
| if [ "$STATUS" = "SUCCEEDED" ]; then | ||
| echo "✅ App upload successful" | ||
| break | ||
| fi | ||
| if [ "$STATUS" = "FAILED" ]; then | ||
| echo "❌ Upload failed" | ||
| aws devicefarm get-upload --arn "$APP_UPLOAD_ARN" | ||
| exit 1 | ||
| fi | ||
| sleep 10 | ||
| ATTEMPT=$((ATTEMPT + 1)) | ||
| done | ||
| - name: Verify test package generation | ||
| working-directory: test-framework/e2e | ||
| run: | | ||
| echo "Verifying e2e test package..." | ||
| if [ ! -f "package.json" ]; then | ||
| echo "❌ ERROR: e2e/package.json not found!" | ||
| exit 1 | ||
| fi | ||
| if [ ! -f "tests/app.test.js" ]; then | ||
| echo "❌ ERROR: e2e/tests/app.test.js not found!" | ||
| exit 1 | ||
| fi | ||
| echo "✅ E2E test files verified" | ||
| echo "" | ||
| echo "Test package contents:" | ||
| ls -la | ||
| echo "" | ||
| echo "Test files:" | ||
| ls -la tests/ | ||
| - name: Package and Upload Test Package | ||
| id: upload_test_package | ||
| working-directory: test-framework | ||
| run: | | ||
| echo "📦 Packaging e2e tests..." | ||
| cd e2e | ||
| # Install dependencies before packing | ||
| npm install --ignore-scripts | ||
| # Create tarball | ||
| npm pack --ignore-scripts | ||
| # Create zip with test files only (no node_modules - will be installed on Device Farm) | ||
| ZIP_NAME="e2e-tests-${{ matrix.platform }}.zip" | ||
| zip -r "$ZIP_NAME" \ | ||
| package.json \ | ||
| tests/ \ | ||
| *.tgz | ||
| echo "📦 Package contents (excluding node_modules):" | ||
| unzip -l "$ZIP_NAME" | head -20 | ||
| # Verify zip was created | ||
| if [ ! -f "$ZIP_NAME" ]; then | ||
| echo "❌ ERROR: Failed to create test package zip" | ||
| exit 1 | ||
| fi | ||
| SIZE=$(du -h "$ZIP_NAME" | cut -f1) | ||
| echo "✅ Test package created: $ZIP_NAME (Size: $SIZE)" | ||
| mv "$ZIP_NAME" "$GITHUB_WORKSPACE/" | ||
| # Upload test package to AWS Device Farm | ||
| echo "📤 Uploading test package to AWS Device Farm..." | ||
| UPLOAD_RESPONSE=$(aws devicefarm create-upload \ | ||
| --project-arn "${{ secrets.LLM_AWS_DEVICE_FARM_PROJECT_ARN }}" \ | ||
| --name "$ZIP_NAME" \ | ||
| --type "APPIUM_NODE_TEST_PACKAGE" \ | ||
| --output json) | ||
| if [ $? -ne 0 ]; then | ||
| echo "❌ Error creating test package upload in Device Farm" | ||
| echo "Response: $UPLOAD_RESPONSE" | ||
| exit 1 | ||
| fi | ||
| TEST_UPLOAD_URL=$(echo $UPLOAD_RESPONSE | jq -r '.upload.url') | ||
| TEST_UPLOAD_ARN=$(echo $UPLOAD_RESPONSE | jq -r '.upload.arn') | ||
| echo "test_package_upload_arn=$TEST_UPLOAD_ARN" >> $GITHUB_OUTPUT | ||
| echo "Test package upload ARN: $TEST_UPLOAD_ARN" | ||
| echo "Uploading to: $TEST_UPLOAD_URL" | ||
| curl -T "$GITHUB_WORKSPACE/$ZIP_NAME" "$TEST_UPLOAD_URL" | ||
| if [ $? -ne 0 ]; then | ||
| echo "❌ Error uploading test package using curl" | ||
| exit 1 | ||
| fi | ||
| # Wait for processing | ||
| echo "⏳ Waiting for test package to be processed..." | ||
| MAX_ATTEMPTS=30 | ||
| ATTEMPT=1 | ||
| while [ $ATTEMPT -le $MAX_ATTEMPTS ]; do | ||
| STATUS=$(aws devicefarm get-upload --arn "$TEST_UPLOAD_ARN" --query "upload.status" --output text) | ||
| echo "Test package status (attempt $ATTEMPT/$MAX_ATTEMPTS): $STATUS" | ||
| if [ "$STATUS" = "SUCCEEDED" ]; then | ||
| echo "✅ Test package upload successful" | ||
| break | ||
| fi | ||
| if [ "$STATUS" = "FAILED" ]; then | ||
| echo "❌ Test package upload failed" | ||
| aws devicefarm get-upload --arn "$TEST_UPLOAD_ARN" | ||
| exit 1 | ||
| fi | ||
| sleep 10 | ||
| ATTEMPT=$((ATTEMPT + 1)) | ||
| done | ||
| if [ $ATTEMPT -gt $MAX_ATTEMPTS ]; then | ||
| echo "❌ Timeout waiting for test package processing" | ||
| exit 1 | ||
| fi | ||
| # NOTE: Everything below remains unchanged from your source workflow. | ||
| # The only monorepo-related change in this entire file is that "addon" operations | ||
| # now target addon/packages/qvac-lib-infer-llamacpp-llm via env.ADDON_WORKDIR. | ||
| - name: Create and Upload Test Spec | ||
| id: upload_test_spec | ||
| run: | | ||
| echo "📝 Creating test spec for custom environment mode..." | ||
| echo "Platform: ${{ matrix.platform }}" | ||
| # Create platform-specific test spec using printf for precise control | ||
| # NOTE: Both platforms use a 'before' hook in the wdio config to click the button | ||
| # This ensures a single Appium session for reliability (no session handoff issues) | ||
| # The before hook includes crash detection using queryAppState | ||
| if [ "${{ matrix.platform }}" == "Android" ]; then | ||
| PLATFORM="Android" | ||
| AUTOMATION="UiAutomator2" | ||
| HOST_LINE="android_test_host: amazon_linux_2" | ||
| BUNDLE_ID="${{ env.APP_BUNDLE_ID }}" | ||
| # Android wdio config with crash detection and test filter support. | ||
| # On crash, checkAppCrash/crashMonitor setTimeout-exit with a 5s | ||
| # delay so logcat has time to drain bare stdout / native logs that | ||
| # would otherwise be lost when process.exit(1) tears down Appium | ||
| # before Device Farm finalises the artifact bundle. | ||
| # __TEST_FILTER__, __QVAC_PERF_RUNS__, __QVAC_PERF_WARMUP_RUNS__ | ||
| # placeholders are replaced per-split by make_split(). | ||
| WDIO_CONFIG='exports.config={runner:"local",hostname:"127.0.0.1",port:4723,path:"/wd/hub",specs:["*.spec.js","*.test.js"],maxInstances:1,bail:0,capabilities:[{platformName:"Android","appium:automationName":"UiAutomator2","appium:appPackage":"'${{ env.APP_BUNDLE_ID }}'","appium:appActivity":"'${{ env.APP_BUNDLE_ID }}'.MainActivity","appium:newCommandTimeout":300,"appium:autoGrantPermissions":true,"appium:autoAcceptAlerts":true,"appium:noReset":true,"appium:dontStopAppOnReset":true,"appium:forceAppLaunch":false}],logLevel:"debug",waitforTimeout:120000,connectionRetryTimeout:30000,connectionRetryCount:3,services:[],framework:"mocha",reporters:["spec"],mochaOpts:{ui:"bdd",timeout:1800000,grep:"__MOCHA_GREP__"},before:async function(capabilities,specs,browser){const BUNDLE_ID="'${{ env.APP_BUNDLE_ID }}'";const TEST_FILTER="__TEST_FILTER__";const QVAC_PERF_RUNS_VALUE="__QVAC_PERF_RUNS__";const QVAC_PERF_WARMUP_RUNS_VALUE="__QVAC_PERF_WARMUP_RUNS__";global.appCrashed=false;global.checkAppCrash=async(stage)=>{try{const state=await browser.queryAppState(BUNDLE_ID);console.log("["+stage+"] App state: "+state+" (4=foreground,3=background,1=not running)");if(state<3){console.error("\\n🛑 APP CRASHED at "+stage+"! State="+state);console.error("Check device logs for BareKit/native errors.\\n");global.appCrashed=true;setTimeout(function(){process.exit(1);},5000);}return state;}catch(e){console.log("["+stage+"] queryAppState error: "+e.message);return-1;}};console.log("Checking initial app state...");await global.checkAppCrash("startup");console.log("Waiting for app to initialize...");await browser.pause(5000);await global.checkAppCrash("after-pause");const initText=await browser.$("android=new UiSelector().textContains(\"INITIALIZED\")");await initText.waitForDisplayed({timeout:60000});await global.checkAppCrash("after-init");if(TEST_FILTER!=="__TEST_FILTER__"){try{const b64=Buffer.from(TEST_FILTER).toString("base64");await browser.pushFile("/data/local/tmp/testFilter.txt",b64);console.log("Pushed test filter: "+TEST_FILTER);}catch(e){console.log("pushFile failed: "+e.message);}}if(QVAC_PERF_RUNS_VALUE.length>0||QVAC_PERF_WARMUP_RUNS_VALUE.length>0){try{var perfCfg="QVAC_PERF_RUNS="+QVAC_PERF_RUNS_VALUE+"\\nQVAC_PERF_WARMUP_RUNS="+QVAC_PERF_WARMUP_RUNS_VALUE+"\\n";var pcb64=Buffer.from(perfCfg).toString("base64");await browser.pushFile("/data/local/tmp/qvacPerfConfig.txt",pcb64);console.log("Pushed perf config: "+perfCfg.replace(/\\n/g," "));}catch(e){console.log("perfConfig pushFile failed: "+e.message);}}console.log("App initialized, clicking Run Automated Tests...");const button=await browser.$("android=new UiSelector().textContains(\"Run Automated Tests\")");await button.waitForDisplayed({timeout:15000});await button.click();console.log("Button clicked!");await browser.pause(5000);await global.checkAppCrash("after-click");global.crashMonitor=setInterval(async()=>{if(global.appCrashed)return;try{const s=await browser.queryAppState(BUNDLE_ID);if(s<3){console.error("\\n🛑 BACKGROUND CRASH DETECTED! App state="+s);global.appCrashed=true;setTimeout(function(){process.exit(1);},5000);}}catch(e){}},15000);},after:async function(){if(global.crashMonitor)clearInterval(global.crashMonitor);},afterTest:async function(test,context,{error}){if(global.appCrashed)return;await global.checkAppCrash("after-test:"+test.title);}};' | ||
| else | ||
| PLATFORM="iOS" | ||
| AUTOMATION="XCUITest" | ||
| # iOS 18+ requires macos_sequoia test host (supports iOS 15-26) | ||
| HOST_LINE="ios_test_host: macos_sequoia" | ||
| BUNDLE_ID="${{ env.APP_BUNDLE_ID }}" | ||
| # iOS wdio config with crash detection (bail:0 = continue on test | ||
| # failures). On crash we (a) pre-register a 5 s setTimeout | ||
| # process.exit BEFORE attempting any flush — this is the hard | ||
| # upper bound on the crash path so a wedged Appium/WDA cannot | ||
| # turn a crash into a long CI timeout — then (b) best-effort | ||
| # attempt an Appium pull_file of bare_console.log via | ||
| # global.flushBareLog so the C++ side log ends up in | ||
| # $DEVICEFARM_LOG_DIR (and therefore in Customer_Artifacts.zip) | ||
| # even when the test crashes before the after-hook would have | ||
| # run. The flush is wrapped in Promise.race against a 3 s | ||
| # timeout so a hung pull_file cannot extend the crash exit | ||
| # past the 5 s budget. The after hook reuses the same helper | ||
| # on the normal completion path. | ||
| # usePrebuiltWDA uses Device Farm's pre-built WebDriverAgent | ||
| # Increased timeout to 30 minutes (1800000ms) for long-running LLM tests | ||
| WDIO_CONFIG='exports.config={runner:"local",hostname:"127.0.0.1",port:4723,path:"/wd/hub",specs:["*.spec.js","*.test.js"],maxInstances:1,bail:0,capabilities:[{platformName:"iOS","appium:automationName":"XCUITest","appium:bundleId":"'${{ env.APP_BUNDLE_ID }}'","appium:newCommandTimeout":300,"appium:noReset":true,"appium:forceAppLaunch":false,"appium:usePrebuiltWDA":true,"appium:wdaLocalPort":8100,"appium:showIOSLog":true,"appium:realDeviceLogger":"/usr/local/lib/node_modules/appium/node_modules/deviceconsole/deviceconsole"}],logLevel:"debug",waitforTimeout:120000,connectionRetryTimeout:30000,connectionRetryCount:3,services:[],framework:"mocha",reporters:["spec"],mochaOpts:{ui:"bdd",timeout:1800000,grep:"__MOCHA_GREP__"},before:async function(capabilities,specs,browser){const BUNDLE_ID="'${{ env.APP_BUNDLE_ID }}'";const TEST_FILTER="__TEST_FILTER__";const QVAC_PERF_RUNS_VALUE="__QVAC_PERF_RUNS__";const QVAC_PERF_WARMUP_RUNS_VALUE="__QVAC_PERF_WARMUP_RUNS__";global.appCrashed=false;global.flushBareLog=async function(reason){try{var _h=require("http");var lb64=await new Promise(function(ok,fail){var bd=JSON.stringify({path:"@"+BUNDLE_ID+":documents/bare_console.log"});var rq=_h.request({hostname:"127.0.0.1",port:4723,path:"/wd/hub/session/"+browser.sessionId+"/appium/device/pull_file",method:"POST",headers:{"Content-Type":"application/json","Content-Length":Buffer.byteLength(bd)}},function(rs){var d="";rs.on("data",function(c){d+=c;});rs.on("end",function(){try{ok(JSON.parse(d).value);}catch(e){fail(e);}});});rq.on("error",fail);rq.write(bd);rq.end();});var logTxt=Buffer.from(lb64,"base64").toString();var logDir=process.env.DEVICEFARM_LOG_DIR||".";require("fs").writeFileSync(logDir+"/bare_console.log",logTxt);console.log("[bare-log] "+reason+" flush ok ("+logTxt.length+" bytes)");}catch(e){console.log("[bare-log] "+reason+" flush failed: "+e.message);}};global.checkAppCrash=async(stage)=>{try{const state=await browser.queryAppState(BUNDLE_ID);console.log("["+stage+"] App state: "+state+" (4=foreground,3=background,1=not running)");if(state<3){console.error("\\n🛑 APP CRASHED at "+stage+"! State="+state);console.error("Check device logs for BareKit/native errors.\\n");global.appCrashed=true;setTimeout(function(){process.exit(1);},5000);try{await browser.pause(1500);await Promise.race([global.flushBareLog("crash-"+stage),new Promise(function(_,rj){setTimeout(function(){rj(new Error("bare-log flush timed out"));},3000);})]);}catch(_){}}return state;}catch(e){console.log("["+stage+"] queryAppState error: "+e.message);return-1;}};console.log("Checking initial app state...");await global.checkAppCrash("startup");console.log("Waiting for app to initialize...");await browser.pause(5000);await global.checkAppCrash("after-pause");const initText=await browser.$("-ios predicate string:label CONTAINS \"INITIALIZED\"");await initText.waitForDisplayed({timeout:60000});await global.checkAppCrash("after-init");if(TEST_FILTER!=="__TEST_FILTER__"){try{const b64=Buffer.from(TEST_FILTER).toString("base64");await browser.pushFile("@"+BUNDLE_ID+":documents/testFilter.txt",b64);console.log("Pushed test filter: "+TEST_FILTER);}catch(e){console.log("pushFile failed: "+e.message);}}if(QVAC_PERF_RUNS_VALUE.length>0||QVAC_PERF_WARMUP_RUNS_VALUE.length>0){try{var perfCfg="QVAC_PERF_RUNS="+QVAC_PERF_RUNS_VALUE+"\\nQVAC_PERF_WARMUP_RUNS="+QVAC_PERF_WARMUP_RUNS_VALUE+"\\n";var pcb64=Buffer.from(perfCfg).toString("base64");await browser.pushFile("@"+BUNDLE_ID+":documents/qvacPerfConfig.txt",pcb64);console.log("Pushed perf config: "+perfCfg.replace(/\\n/g," "));}catch(e){console.log("perfConfig pushFile failed: "+e.message);}}console.log("App initialized, clicking Run Automated Tests...");const button=await browser.$("-ios predicate string:label CONTAINS \"Run Automated Tests\"");await button.waitForDisplayed({timeout:15000});await button.click();console.log("Button clicked!");await browser.pause(5000);await global.checkAppCrash("after-click");global.crashMonitor=setInterval(async()=>{if(global.appCrashed)return;try{const s=await browser.queryAppState(BUNDLE_ID);if(s<3){console.error("\\n🛑 BACKGROUND CRASH DETECTED! App state="+s);global.appCrashed=true;setTimeout(function(){process.exit(1);},5000);try{await browser.pause(1500);await Promise.race([global.flushBareLog("crash-bg"),new Promise(function(_,rj){setTimeout(function(){rj(new Error("bare-log flush timed out"));},3000);})]);}catch(_){}}}catch(e){}},15000);},after:async function(){if(global.crashMonitor)clearInterval(global.crashMonitor);console.log("[bare-log] Waiting for log flush...");await browser.pause(3000);if(global.flushBareLog)await global.flushBareLog("after");},afterTest:async function(test,context,{error}){if(global.appCrashed)return;await global.checkAppCrash("after-test:"+test.title);}};' | ||
| fi | ||
| WDIO_B64=$(echo "$WDIO_CONFIG" | base64 | tr -d '\n') | ||
| generate_spec() { | ||
| local output_file="$1" | ||
| local wdio_b64="$2" | ||
| { | ||
| printf 'version: 0.1\n' | ||
| if [ -n "$HOST_LINE" ]; then | ||
| printf '%s\n' "$HOST_LINE" | ||
| fi | ||
| printf '\nphases:\n install:\n commands:\n' | ||
| printf ' - echo "Setting up Node.js environment..."\n' | ||
| printf ' - export NVM_DIR=$HOME/.nvm\n' | ||
| printf ' - . $NVM_DIR/nvm.sh 2>/dev/null || true\n' | ||
| printf ' - nvm install 18 2>/dev/null || true\n' | ||
| printf ' - nvm use 18 2>/dev/null || true\n' | ||
| printf ' - node --version || echo "Using system node"\n' | ||
| printf '\n pre_test:\n commands:\n' | ||
| printf ' - echo "Setting up test environment..."\n' | ||
| printf ' - cd $DEVICEFARM_TEST_PACKAGE_PATH\n' | ||
| printf ' - ls -la\n' | ||
| printf ' - echo "Installing dependencies (clean install)..."\n' | ||
| printf ' - rm -rf node_modules package-lock.json 2>/dev/null || true\n' | ||
| printf ' - npm install --legacy-peer-deps 2>&1\n' | ||
| printf ' - echo "Verifying wdio installation..."\n' | ||
| printf ' - ls -la node_modules/.bin/ | grep wdio || echo "wdio not found in .bin"\n' | ||
| printf ' - node node_modules/@wdio/cli/bin/wdio.js --version || echo "wdio version check failed"\n' | ||
| printf ' - echo "Creating wdio config for Device Farm..."\n' | ||
| printf ' - echo "%s" | base64 -d > tests/wdio.config.devicefarm.js\n' "$wdio_b64" | ||
| printf ' - cat tests/wdio.config.devicefarm.js\n' | ||
| if [ "${{ matrix.platform }}" == "iOS" ]; then | ||
| printf ' - echo "Configuring WebDriverAgent for iOS..."\n' | ||
| printf ' - export DEVICEFARM_APPIUM_WDA_DERIVED_DATA_PATH=$DEVICEFARM_APPIUM_WDA_DERIVED_DATA_PATH_V9\n' | ||
| printf ' - echo "WDA Path: $DEVICEFARM_APPIUM_WDA_DERIVED_DATA_PATH"\n' | ||
| fi | ||
| printf ' - echo "Starting Appium server..."\n' | ||
| printf ' - export APPIUM_BASE_PATH=/wd/hub\n' | ||
| printf ' - |\n' | ||
| printf ' appium --base-path=$APPIUM_BASE_PATH --log-timestamp \\\n' | ||
| printf ' --log-no-colors --relaxed-security --default-capabilities \\\n' | ||
| printf ' "{\\"appium:deviceName\\": \\"$DEVICEFARM_DEVICE_NAME\\", \\\n' | ||
| printf ' \\"platformName\\": \\"$DEVICEFARM_DEVICE_PLATFORM_NAME\\", \\\n' | ||
| printf ' \\"appium:app\\": \\"$DEVICEFARM_APP_PATH\\", \\\n' | ||
| printf ' \\"appium:udid\\":\\"$DEVICEFARM_DEVICE_UDID\\", \\\n' | ||
| printf ' \\"appium:platformVersion\\": \\"$DEVICEFARM_DEVICE_OS_VERSION\\", \\\n' | ||
| printf ' \\"appium:chromedriverExecutableDir\\": \\"$DEVICEFARM_CHROMEDRIVER_EXECUTABLE_DIR\\", \\\n' | ||
| printf ' \\"appium:wdaLocalPort\\": 8100, \\\n' | ||
| printf ' \\"appium:derivedDataPath\\": \\"$DEVICEFARM_APPIUM_WDA_DERIVED_DATA_PATH\\", \\\n' | ||
| printf ' \\"appium:usePrebuiltWDA\\": true, \\\n' | ||
| printf ' \\"appium:automationName\\": \\"%s\\"}" \\\n' "$AUTOMATION" | ||
| printf ' >> $DEVICEFARM_LOG_DIR/appium.log 2>&1 &\n' | ||
| printf ' - echo "Waiting for Appium to be ready (max 30 seconds)..."\n' | ||
| printf ' - |\n' | ||
| printf ' appium_initialization_time=0\n' | ||
| printf ' until curl --silent --fail "http://0.0.0.0:4723${APPIUM_BASE_PATH}/status"; do\n' | ||
| printf ' if [[ $appium_initialization_time -gt 30 ]]; then\n' | ||
| printf ' echo "Appium did not start within 30 seconds. Exiting..."\n' | ||
| printf ' cat $DEVICEFARM_LOG_DIR/appium.log\n' | ||
| printf ' exit 1\n' | ||
| printf ' fi\n' | ||
| printf ' appium_initialization_time=$((appium_initialization_time + 1))\n' | ||
| printf ' echo "Waiting for Appium to start on port 4723 (${appium_initialization_time}s/30s)..."\n' | ||
| printf ' sleep 1\n' | ||
| printf ' done\n' | ||
| printf ' - echo "Appium server is ready!"\n' | ||
| printf ' - curl -s http://0.0.0.0:4723${APPIUM_BASE_PATH}/status || echo "Status check failed"\n' | ||
| printf '\n test:\n commands:\n' | ||
| printf ' - echo "Running WebDriverIO tests..."\n' | ||
| printf ' - cd $DEVICEFARM_TEST_PACKAGE_PATH\n' | ||
| printf ' - ps aux | grep appium | grep -v grep || echo "Appium process not found"\n' | ||
| printf ' - curl -s http://127.0.0.1:4723/wd/hub/status || echo "Appium status check failed"\n' | ||
| printf ' - node node_modules/@wdio/cli/bin/wdio.js run tests/wdio.config.devicefarm.js\n' | ||
| printf '\n post_test:\n commands:\n' | ||
| printf ' - echo "Test completed"\n' | ||
| if [ "${{ matrix.platform }}" == "iOS" ]; then | ||
| printf ' - echo ""\n' | ||
| printf ' - echo "iOS Device Console Logs"\n' | ||
| printf ' - |\n' | ||
| printf ' if [ -f "$DEVICEFARM_LOG_DIR/device_console.log" ]; then\n' | ||
| printf ' grep -i "bare\\|console\\|model\\|embedding\\|test\\|error" "$DEVICEFARM_LOG_DIR/device_console.log" || echo "No matching logs"\n' | ||
| printf ' else\n' | ||
| printf ' echo "No device_console.log found"\n' | ||
| printf ' fi\n' | ||
| printf ' - ls -lh $DEVICEFARM_LOG_DIR/ || true\n' | ||
| fi | ||
| printf '\nartifacts:\n - $DEVICEFARM_LOG_DIR\n' | ||
| } > "$output_file" | ||
| echo "Generated: $output_file" | ||
| } | ||
| upload_spec() { | ||
| local spec_file="$1" | ||
| local label="$2" | ||
| local RESP=$(aws devicefarm create-upload \ | ||
| --project-arn "${{ secrets.LLM_AWS_DEVICE_FARM_PROJECT_ARN }}" \ | ||
| --name "$spec_file" \ | ||
| --type "APPIUM_NODE_TEST_SPEC" \ | ||
| --output json) | ||
| local URL=$(echo $RESP | jq -r '.upload.url') | ||
| local ARN=$(echo $RESP | jq -r '.upload.arn') | ||
| curl -T "$spec_file" "$URL" | ||
| local MAX=20; local A=1 | ||
| while [ $A -le $MAX ]; do | ||
| local ST=$(aws devicefarm get-upload --arn "$ARN" --query "upload.status" --output text) | ||
| if [ "$ST" = "SUCCEEDED" ]; then echo "$ARN"; return 0; fi | ||
| if [ "$ST" = "FAILED" ]; then echo "$label spec upload failed" >&2; exit 1; fi | ||
| sleep 5; A=$((A + 1)) | ||
| done | ||
| echo "$label spec upload timed out after $MAX attempts" >&2; exit 1 | ||
| } | ||
| # For each split: inject mocha grep AND replace __TEST_FILTER__ so | ||
| # the before-hook pushes a testFilter.txt the app reads at runtime. | ||
| # This ensures the app only executes matching tests (real splitting). | ||
| # When set, the perf inputs also get substituted so the before-hook | ||
| # pushes a qvacPerfConfig.txt with the iteration overrides. | ||
| QVAC_PERF_RUNS_INPUT="${{ inputs.qvac_perf_runs }}" | ||
| QVAC_PERF_WARMUP_RUNS_INPUT="${{ inputs.qvac_perf_warmup_runs }}" | ||
| make_split() { | ||
| local pattern="$1" output="$2" | ||
| local cfg | ||
| cfg=$(echo "$WDIO_CONFIG" | sed "s#__MOCHA_GREP__#$pattern#") | ||
| cfg=$(echo "$cfg" | sed "s#__TEST_FILTER__#$pattern#") | ||
| cfg=$(echo "$cfg" | sed "s#__QVAC_PERF_RUNS__#$QVAC_PERF_RUNS_INPUT#") | ||
| cfg=$(echo "$cfg" | sed "s#__QVAC_PERF_WARMUP_RUNS__#$QVAC_PERF_WARMUP_RUNS_INPUT#") | ||
| local b64 | ||
| b64=$(echo "$cfg" | base64 | tr -d '\n') | ||
| generate_spec "$output" "$b64" | ||
| } | ||
| GROUPS_JSON="${{ github.workspace }}/${{ env.ADDON_WORKDIR }}/test/mobile/test-groups.json" | ||
| if [ "${{ matrix.platform }}" == "Android" ]; then | ||
| ANDROID_GROUP_A=$(jq -r '.android.groupA | join("|")' "$GROUPS_JSON") | ||
| ANDROID_GROUP_B=$(jq -r '.android.groupB | join("|")' "$GROUPS_JSON") | ||
| echo "Android test split (2 groups):" | ||
| echo " Group A: $ANDROID_GROUP_A" | ||
| echo " Group B: $ANDROID_GROUP_B" | ||
| make_split "$ANDROID_GROUP_A" testspec-android-a.yml | ||
| make_split "$ANDROID_GROUP_B" testspec-android-b.yml | ||
| SPEC_ARN_A=$(upload_spec testspec-android-a.yml "Android-A") | ||
| echo "test_spec_arn=$SPEC_ARN_A" >> $GITHUB_OUTPUT | ||
| SPEC_ARN_B=$(upload_spec testspec-android-b.yml "Android-B") | ||
| echo "test_spec_arn_2=$SPEC_ARN_B" >> $GITHUB_OUTPUT | ||
| else | ||
| IOS_GREP_HEAVY_1=$(jq -r '.ios.heavy1 | join("|")' "$GROUPS_JSON") | ||
| IOS_GREP_HEAVY_2=$(jq -r '.ios.heavy2 | join("|")' "$GROUPS_JSON") | ||
| IOS_GREP_HEAVY_3=$(jq -r '.ios.heavy3 | join("|")' "$GROUPS_JSON") | ||
| IOS_GREP_HEAVY_4=$(jq -r '.ios.heavy4 | join("|")' "$GROUPS_JSON") | ||
| IOS_GREP_HEAVY_5=$(jq -r '.ios.heavy5 | join("|")' "$GROUPS_JSON") | ||
| IOS_GREP_HEAVY_6=$(jq -r '.ios.heavy6 | join("|")' "$GROUPS_JSON") | ||
| IOS_GREP_HEAVY_7=$(jq -r '.ios.heavy7 | join("|")' "$GROUPS_JSON") | ||
| IOS_GREP_HEAVY_8=$(jq -r '.ios.heavy8 | join("|")' "$GROUPS_JSON") | ||
| IOS_GREP_LIGHT_A=$(jq -r '.ios.lightA | join("|")' "$GROUPS_JSON") | ||
| IOS_GREP_LIGHT_B=$(jq -r '.ios.lightB | join("|")' "$GROUPS_JSON") | ||
| echo "iOS test split (8 heavy + 2 light = 10 groups):" | ||
| echo " Heavy 1 (iPhone 16 Pro Max): $IOS_GREP_HEAVY_1" | ||
| echo " Heavy 2 (iPhone 16 Pro Max): $IOS_GREP_HEAVY_2" | ||
| echo " Heavy 3 (iPhone 17): $IOS_GREP_HEAVY_3" | ||
| echo " Heavy 4 (OcrLighton): $IOS_GREP_HEAVY_4" | ||
| echo " Heavy 5 (Reasoning): $IOS_GREP_HEAVY_5" | ||
| echo " Heavy 6 (SlidingContext): $IOS_GREP_HEAVY_6" | ||
| echo " Heavy 7 (Gemma4): $IOS_GREP_HEAVY_7" | ||
| echo " Heavy 8 (OcrPaddle): $IOS_GREP_HEAVY_8" | ||
| echo " Light A (iPhone 16 Pro): $IOS_GREP_LIGHT_A" | ||
| echo " Light B (iPhone 16 Pro): $IOS_GREP_LIGHT_B" | ||
| make_split "$IOS_GREP_HEAVY_1" testspec-heavy-1.yml | ||
| make_split "$IOS_GREP_HEAVY_2" testspec-heavy-2.yml | ||
| make_split "$IOS_GREP_HEAVY_3" testspec-heavy-3.yml | ||
| make_split "$IOS_GREP_HEAVY_4" testspec-heavy-4.yml | ||
| make_split "$IOS_GREP_HEAVY_5" testspec-heavy-5.yml | ||
| make_split "$IOS_GREP_HEAVY_6" testspec-heavy-6.yml | ||
| make_split "$IOS_GREP_HEAVY_7" testspec-heavy-7.yml | ||
| make_split "$IOS_GREP_HEAVY_8" testspec-heavy-8.yml | ||
| make_split "$IOS_GREP_LIGHT_A" testspec-light-a.yml | ||
| make_split "$IOS_GREP_LIGHT_B" testspec-light-b.yml | ||
| SPEC_ARN_H1=$(upload_spec testspec-heavy-1.yml "iOS-Heavy1") | ||
| echo "test_spec_arn=$SPEC_ARN_H1" >> $GITHUB_OUTPUT | ||
| SPEC_ARN_H2=$(upload_spec testspec-heavy-2.yml "iOS-Heavy2") | ||
| echo "test_spec_arn_2=$SPEC_ARN_H2" >> $GITHUB_OUTPUT | ||
| SPEC_ARN_H3=$(upload_spec testspec-heavy-3.yml "iOS-Heavy3") | ||
| echo "test_spec_arn_3=$SPEC_ARN_H3" >> $GITHUB_OUTPUT | ||
| SPEC_ARN_H4=$(upload_spec testspec-heavy-4.yml "iOS-Heavy4") | ||
| echo "test_spec_arn_4=$SPEC_ARN_H4" >> $GITHUB_OUTPUT | ||
| SPEC_ARN_H5=$(upload_spec testspec-heavy-5.yml "iOS-Heavy5") | ||
| echo "test_spec_arn_5=$SPEC_ARN_H5" >> $GITHUB_OUTPUT | ||
| SPEC_ARN_H6=$(upload_spec testspec-heavy-6.yml "iOS-Heavy6") | ||
| echo "test_spec_arn_6=$SPEC_ARN_H6" >> $GITHUB_OUTPUT | ||
| SPEC_ARN_LA=$(upload_spec testspec-light-a.yml "iOS-LightA") | ||
| echo "test_spec_arn_7=$SPEC_ARN_LA" >> $GITHUB_OUTPUT | ||
| SPEC_ARN_LB=$(upload_spec testspec-light-b.yml "iOS-LightB") | ||
| echo "test_spec_arn_8=$SPEC_ARN_LB" >> $GITHUB_OUTPUT | ||
| SPEC_ARN_H7=$(upload_spec testspec-heavy-7.yml "iOS-Heavy7") | ||
| echo "test_spec_arn_9=$SPEC_ARN_H7" >> $GITHUB_OUTPUT | ||
| SPEC_ARN_H8=$(upload_spec testspec-heavy-8.yml "iOS-Heavy8") | ||
| echo "test_spec_arn_10=$SPEC_ARN_H8" >> $GITHUB_OUTPUT | ||
| fi | ||
| - name: Schedule Device Farm Test Run | ||
| id: schedule_run | ||
| run: | | ||
| if [ "${{ github.event_name }}" == "workflow_dispatch" ]; then | ||
| RUN_NAME="Manual-${{ github.run_number }}-${{ matrix.platform }}" | ||
| else | ||
| RUN_NAME="PR-${{ github.event.pull_request.number || github.run_number }}-${{ matrix.platform }}" | ||
| fi | ||
| PROJECT_ARN="${{ secrets.LLM_AWS_DEVICE_FARM_PROJECT_ARN }}" | ||
| APP_ARN="${{ steps.upload_app.outputs.app_upload_arn }}" | ||
| TEST_PACKAGE_ARN="${{ steps.upload_test_package.outputs.test_package_upload_arn }}" | ||
| schedule_run_with_pool() { | ||
| local pool_arn="$1" | ||
| local name="$2" | ||
| local spec_arn="$3" | ||
| aws devicefarm schedule-run \ | ||
| --project-arn "$PROJECT_ARN" \ | ||
| --device-pool-arn "$pool_arn" \ | ||
| --app-arn "$APP_ARN" \ | ||
| --name "$name" \ | ||
| --test "type=APPIUM_NODE,testPackageArn=$TEST_PACKAGE_ARN,testSpecArn=$spec_arn" \ | ||
| --execution-configuration jobTimeoutMinutes=60 \ | ||
| --query 'run.arn' --output text | ||
| } | ||
| if [ "${{ matrix.platform }}" == "Android" ]; then | ||
| POOL_ARN="${{ secrets.LLM_ANDROID_DEVICE_POOL_ARN }}" | ||
| TEST_SPEC_ARN_A="${{ steps.upload_test_spec.outputs.test_spec_arn }}" | ||
| TEST_SPEC_ARN_B="${{ steps.upload_test_spec.outputs.test_spec_arn_2 }}" | ||
| echo "🚀 Scheduling 2 Android runs (each runs on all devices in pool)..." | ||
| echo "Device Pool ARN: $POOL_ARN" | ||
| RUN_ARN_1=$(schedule_run_with_pool "$POOL_ARN" "$RUN_NAME-Android-GroupA" "$TEST_SPEC_ARN_A") | ||
| echo "✅ Android Group A scheduled: $RUN_ARN_1" | ||
| RUN_ARN_2=$(schedule_run_with_pool "$POOL_ARN" "$RUN_NAME-Android-GroupB" "$TEST_SPEC_ARN_B") | ||
| echo "✅ Android Group B scheduled: $RUN_ARN_2" | ||
| echo "run_arn_1=$RUN_ARN_1" >> $GITHUB_OUTPUT | ||
| echo "run_arn_2=$RUN_ARN_2" >> $GITHUB_OUTPUT | ||
| echo "run_count=2" >> $GITHUB_OUTPUT | ||
| else | ||
| POOL_ARN="${{ secrets.LLM_IOS_DEVICE_POOL_ARN }}" | ||
| TEST_SPEC_ARN_1="${{ steps.upload_test_spec.outputs.test_spec_arn }}" | ||
| TEST_SPEC_ARN_2="${{ steps.upload_test_spec.outputs.test_spec_arn_2 }}" | ||
| TEST_SPEC_ARN_3="${{ steps.upload_test_spec.outputs.test_spec_arn_3 }}" | ||
| TEST_SPEC_ARN_4="${{ steps.upload_test_spec.outputs.test_spec_arn_4 }}" | ||
| TEST_SPEC_ARN_5="${{ steps.upload_test_spec.outputs.test_spec_arn_5 }}" | ||
| TEST_SPEC_ARN_6="${{ steps.upload_test_spec.outputs.test_spec_arn_6 }}" | ||
| TEST_SPEC_ARN_7="${{ steps.upload_test_spec.outputs.test_spec_arn_7 }}" | ||
| TEST_SPEC_ARN_8="${{ steps.upload_test_spec.outputs.test_spec_arn_8 }}" | ||
| TEST_SPEC_ARN_9="${{ steps.upload_test_spec.outputs.test_spec_arn_9 }}" | ||
| TEST_SPEC_ARN_10="${{ steps.upload_test_spec.outputs.test_spec_arn_10 }}" | ||
| echo "🚀 Scheduling 10 iOS runs (each runs on ALL devices in pool)..." | ||
| echo "Device Pool ARN: $POOL_ARN" | ||
| RUN_ARN_1=$(schedule_run_with_pool "$POOL_ARN" "$RUN_NAME-iOS-Heavy1-Finetuning" "$TEST_SPEC_ARN_1") | ||
| echo "✅ Heavy1 (Finetuning) scheduled: $RUN_ARN_1" | ||
| RUN_ARN_2=$(schedule_run_with_pool "$POOL_ARN" "$RUN_NAME-iOS-Heavy2-ToolCalling" "$TEST_SPEC_ARN_2") | ||
| echo "✅ Heavy2 (ToolCalling) scheduled: $RUN_ARN_2" | ||
| RUN_ARN_3=$(schedule_run_with_pool "$POOL_ARN" "$RUN_NAME-iOS-Heavy3-Image" "$TEST_SPEC_ARN_3") | ||
| echo "✅ Heavy3 (Image) scheduled: $RUN_ARN_3" | ||
| RUN_ARN_4=$(schedule_run_with_pool "$POOL_ARN" "$RUN_NAME-iOS-Heavy4-OcrLighton" "$TEST_SPEC_ARN_4") | ||
| echo "✅ Heavy4 (OcrLighton) scheduled: $RUN_ARN_4" | ||
| RUN_ARN_5=$(schedule_run_with_pool "$POOL_ARN" "$RUN_NAME-iOS-Heavy5-Reasoning" "$TEST_SPEC_ARN_5") | ||
| echo "✅ Heavy5 (Reasoning) scheduled: $RUN_ARN_5" | ||
| RUN_ARN_6=$(schedule_run_with_pool "$POOL_ARN" "$RUN_NAME-iOS-Heavy6-SlidingContext" "$TEST_SPEC_ARN_6") | ||
| echo "✅ Heavy6 (SlidingContext) scheduled: $RUN_ARN_6" | ||
| RUN_ARN_7=$(schedule_run_with_pool "$POOL_ARN" "$RUN_NAME-iOS-LightA" "$TEST_SPEC_ARN_7") | ||
| echo "✅ LightA scheduled: $RUN_ARN_7" | ||
| RUN_ARN_8=$(schedule_run_with_pool "$POOL_ARN" "$RUN_NAME-iOS-LightB" "$TEST_SPEC_ARN_8") | ||
| echo "✅ LightB scheduled: $RUN_ARN_8" | ||
| RUN_ARN_9=$(schedule_run_with_pool "$POOL_ARN" "$RUN_NAME-iOS-Heavy7-Gemma4" "$TEST_SPEC_ARN_9") | ||
| echo "✅ Heavy7 (Gemma4) scheduled: $RUN_ARN_9" | ||
| RUN_ARN_10=$(schedule_run_with_pool "$POOL_ARN" "$RUN_NAME-iOS-Heavy8-OcrPaddle" "$TEST_SPEC_ARN_10") | ||
| echo "✅ Heavy8 (OcrPaddle) scheduled: $RUN_ARN_10" | ||
| echo "run_arn_1=$RUN_ARN_1" >> $GITHUB_OUTPUT | ||
| echo "run_arn_2=$RUN_ARN_2" >> $GITHUB_OUTPUT | ||
| echo "run_arn_3=$RUN_ARN_3" >> $GITHUB_OUTPUT | ||
| echo "run_arn_4=$RUN_ARN_4" >> $GITHUB_OUTPUT | ||
| echo "run_arn_5=$RUN_ARN_5" >> $GITHUB_OUTPUT | ||
| echo "run_arn_6=$RUN_ARN_6" >> $GITHUB_OUTPUT | ||
| echo "run_arn_7=$RUN_ARN_7" >> $GITHUB_OUTPUT | ||
| echo "run_arn_8=$RUN_ARN_8" >> $GITHUB_OUTPUT | ||
| echo "run_arn_9=$RUN_ARN_9" >> $GITHUB_OUTPUT | ||
| echo "run_arn_10=$RUN_ARN_10" >> $GITHUB_OUTPUT | ||
| echo "run_count=10" >> $GITHUB_OUTPUT | ||
| fi | ||
| echo "All runs scheduled." | ||
| - name: Refresh AWS credentials for monitoring | ||
| uses: aws-actions/configure-aws-credentials@8df5847569e6427dd6c4fb1cf565c83acfa8afa7 # 6.0.0 | ||
| with: | ||
| role-to-assume: ${{ secrets.AWS_OIDC_ROLE_ARN }} | ||
| aws-region: us-west-2 | ||
| role-session-name: device-farm-monitor | ||
| - name: Monitor Test Run | ||
| id: monitor_run | ||
| run: | | ||
| aws_retry() { | ||
| local max_attempts=3 | ||
| local attempt=1 | ||
| local result="" | ||
| while [ $attempt -le $max_attempts ]; do | ||
| result=$("$@" 2>/dev/null) && { echo "$result"; return 0; } | ||
| echo " ⚠️ AWS API attempt $attempt/$max_attempts failed, retrying in 5s..." >&2 | ||
| sleep 5 | ||
| attempt=$((attempt + 1)) | ||
| done | ||
| echo "" | ||
| return 1 | ||
| } | ||
| RUN_ARN_1="${{ steps.schedule_run.outputs.run_arn_1 }}" | ||
| RUN_ARN_2="${{ steps.schedule_run.outputs.run_arn_2 }}" | ||
| RUN_ARN_3="${{ steps.schedule_run.outputs.run_arn_3 }}" | ||
| RUN_ARN_4="${{ steps.schedule_run.outputs.run_arn_4 }}" | ||
| RUN_ARN_5="${{ steps.schedule_run.outputs.run_arn_5 }}" | ||
| RUN_ARN_6="${{ steps.schedule_run.outputs.run_arn_6 }}" | ||
| RUN_ARN_7="${{ steps.schedule_run.outputs.run_arn_7 }}" | ||
| RUN_ARN_8="${{ steps.schedule_run.outputs.run_arn_8 }}" | ||
| RUN_ARN_9="${{ steps.schedule_run.outputs.run_arn_9 }}" | ||
| RUN_ARN_10="${{ steps.schedule_run.outputs.run_arn_10 }}" | ||
| RUN_COUNT="${{ steps.schedule_run.outputs.run_count }}" | ||
| echo "📊 Monitoring $RUN_COUNT Device Farm run(s)..." | ||
| for i in $(seq 1 "$RUN_COUNT"); do | ||
| eval "echo \" Run $i: \$RUN_ARN_$i\"" | ||
| done | ||
| echo "" | ||
| MAX_WAIT_TIME=7200 | ||
| ELAPSED=0 | ||
| for i in $(seq 1 "$RUN_COUNT"); do eval "DONE_$i=false"; done | ||
| while true; do | ||
| STATUS_LINE="⏳" | ||
| for i in $(seq 1 "$RUN_COUNT"); do | ||
| eval "done_val=\$DONE_$i" | ||
| if [[ "$done_val" != "true" ]]; then | ||
| eval "arn=\$RUN_ARN_$i" | ||
| STATUS_VAL=$(aws_retry aws devicefarm get-run --arn "$arn" --query 'run.status' --output text) || true | ||
| RESULT_VAL=$(aws_retry aws devicefarm get-run --arn "$arn" --query 'run.result' --output text) || true | ||
| eval "STATUS_$i=\"$STATUS_VAL\"" | ||
| eval "RESULT_$i=\"$RESULT_VAL\"" | ||
| if [[ "$STATUS_VAL" == "COMPLETED" ]]; then eval "DONE_$i=true"; fi | ||
| fi | ||
| eval "STATUS_LINE=\"\$STATUS_LINE Run $i: \$STATUS_$i (\$RESULT_$i) |\"" | ||
| done | ||
| echo "$STATUS_LINE ${ELAPSED}s" | ||
| ALL_DONE=true | ||
| for i in $(seq 1 "$RUN_COUNT"); do | ||
| eval "done_val=\$DONE_$i" | ||
| if [[ "$done_val" != "true" ]]; then ALL_DONE=false; fi | ||
| done | ||
| if [[ "$ALL_DONE" == "true" ]]; then | ||
| echo "" | ||
| echo "✅ All runs completed!" | ||
| break | ||
| fi | ||
| if [ $ELAPSED -ge $MAX_WAIT_TIME ]; then | ||
| echo "" | ||
| echo "❌ Timeout: exceeded ${MAX_WAIT_TIME}s" | ||
| exit 1 | ||
| fi | ||
| sleep 30 | ||
| ELAPSED=$((ELAPSED + 30)) | ||
| done | ||
| RUN_ARNS=("$RUN_ARN_1") | ||
| for i in $(seq 2 "$RUN_COUNT"); do | ||
| eval "arn=\$RUN_ARN_$i" | ||
| if [ -n "$arn" ]; then | ||
| RUN_ARNS+=("$arn") | ||
| fi | ||
| done | ||
| DEVICE_COUNT=0 | ||
| USER_TEST_COUNT=0 | ||
| USER_PASSED=0 | ||
| USER_FAILED=0 | ||
| FAILED_TEST_DETAILS=() | ||
| ALL_RESULTS_PASSED=true | ||
| TOTAL_TOTAL=0 | ||
| TOTAL_PASSED=0 | ||
| TOTAL_FAILED=0 | ||
| TOTAL_SKIPPED=0 | ||
| echo "" | ||
| echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" | ||
| echo "📊 FINAL TEST RESULTS" | ||
| echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" | ||
| echo "" | ||
| RUN_INDEX=0 | ||
| for RUN_ARN in "${RUN_ARNS[@]}"; do | ||
| RUN_INDEX=$((RUN_INDEX + 1)) | ||
| RUN_DETAILS=$(aws_retry aws devicefarm get-run --arn "$RUN_ARN" --output json) || true | ||
| [ -z "$RUN_DETAILS" ] && RUN_DETAILS='{}' | ||
| RESULT=$(echo "$RUN_DETAILS" | jq -r '.run.result // "UNKNOWN"') | ||
| RUN_NAME_LABEL=$(echo "$RUN_DETAILS" | jq -r '.run.name // "unknown"') | ||
| COUNTERS=$(echo "$RUN_DETAILS" | jq -r '.run.counters // {}') | ||
| if [[ "$RESULT" != "PASSED" && "$RESULT" != "SKIPPED" ]]; then ALL_RESULTS_PASSED=false; fi | ||
| TOTAL_TOTAL=$((TOTAL_TOTAL + $(echo "$COUNTERS" | jq -r '.total // 0'))) | ||
| TOTAL_PASSED=$((TOTAL_PASSED + $(echo "$COUNTERS" | jq -r '.passed // 0'))) | ||
| TOTAL_FAILED=$((TOTAL_FAILED + $(echo "$COUNTERS" | jq -r '.failed // 0'))) | ||
| TOTAL_SKIPPED=$((TOTAL_SKIPPED + $(echo "$COUNTERS" | jq -r '.skipped // 0'))) | ||
| PROJECT_ID=$(echo "$RUN_ARN" | sed -n 's/.*:run:\([^/]*\)\/.*/\1/p') | ||
| RUN_ID=$(echo "$RUN_ARN" | sed -n 's/.*:run:[^/]*\/\(.*\)/\1/p') | ||
| if [ "$RUN_COUNT" -ge 2 ]; then | ||
| echo "--- Run $RUN_INDEX: $RUN_NAME_LABEL (Result: $RESULT) ---" | ||
| else | ||
| echo "Result: $RESULT" | ||
| fi | ||
| JOBS=$(aws_retry aws devicefarm list-jobs --arn "$RUN_ARN" --output json) || true | ||
| [ -z "$JOBS" ] && JOBS='{"jobs":[]}' | ||
| for JOB_ARN in $(echo "$JOBS" | jq -r '.jobs[].arn'); do | ||
| DEVICE_COUNT=$((DEVICE_COUNT + 1)) | ||
| JOB_DETAILS=$(aws_retry aws devicefarm get-job --arn "$JOB_ARN" --output json) || true | ||
| [ -z "$JOB_DETAILS" ] && JOB_DETAILS='{}' | ||
| DEVICE_NAME=$(echo "$JOB_DETAILS" | jq -r '.job.device.name // "Unknown Device"') | ||
| JOB_RESULT=$(echo "$JOB_DETAILS" | jq -r '.job.result // "UNKNOWN"') | ||
| JOB_ID=$(echo "$JOB_ARN" | sed -n 's/.*:job:[^/]*\/[^/]*\/\(.*\)/\1/p') | ||
| CONSOLE_LINK="https://us-west-2.console.aws.amazon.com/devicefarm/home#/mobile/projects/${PROJECT_ID}/runs/${RUN_ID}/jobs/${JOB_ID}" | ||
| if [ "$JOB_RESULT" = "PASSED" ]; then | ||
| echo " ✅ $DEVICE_NAME: PASSED" | ||
| USER_PASSED=$((USER_PASSED + 1)) | ||
| else | ||
| echo " ❌ $DEVICE_NAME: $JOB_RESULT" | ||
| USER_FAILED=$((USER_FAILED + 1)) | ||
| FAILED_TEST_DETAILS+=("❌ $DEVICE_NAME: $JOB_RESULT") | ||
| FAILED_TEST_DETAILS+=(" 📎 View logs: $CONSOLE_LINK") | ||
| fi | ||
| USER_TEST_COUNT=$((USER_TEST_COUNT + 1)) | ||
| done | ||
| echo " 🔗 Run link: https://us-west-2.console.aws.amazon.com/devicefarm/home#/mobile/projects/${PROJECT_ID}/runs/${RUN_ID}" | ||
| echo "" | ||
| done | ||
| echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" | ||
| echo "📊 SUMMARY" | ||
| echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" | ||
| echo "" | ||
| echo "Devices tested: $DEVICE_COUNT" | ||
| echo " ✅ Passed: $USER_PASSED" | ||
| echo " ❌ Failed: $USER_FAILED" | ||
| echo "" | ||
| if [ ${#FAILED_TEST_DETAILS[@]} -gt 0 ]; then | ||
| echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" | ||
| echo "❌ FAILED TESTS" | ||
| echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" | ||
| for failed_info in "${FAILED_TEST_DETAILS[@]}"; do | ||
| echo "$failed_info" | ||
| done | ||
| echo "" | ||
| fi | ||
| echo "Device Farm totals: $TOTAL_TOTAL | Passed: $TOTAL_PASSED | Failed: $TOTAL_FAILED | Skipped: $TOTAL_SKIPPED" | ||
| echo "" | ||
| if [ $USER_FAILED -gt 0 ]; then | ||
| echo "test_result=FAILED" >> $GITHUB_OUTPUT | ||
| elif [ $USER_PASSED -gt 0 ]; then | ||
| echo "test_result=PASSED" >> $GITHUB_OUTPUT | ||
| else | ||
| echo "test_result=FAILED" >> $GITHUB_OUTPUT | ||
| fi | ||
| echo "test_total=$TOTAL_TOTAL" >> $GITHUB_OUTPUT | ||
| echo "test_passed=$TOTAL_PASSED" >> $GITHUB_OUTPUT | ||
| echo "test_failed=$TOTAL_FAILED" >> $GITHUB_OUTPUT | ||
| echo "test_skipped=$TOTAL_SKIPPED" >> $GITHUB_OUTPUT | ||
| echo "user_test_count=$USER_TEST_COUNT" >> $GITHUB_OUTPUT | ||
| echo "user_test_passed=$USER_PASSED" >> $GITHUB_OUTPUT | ||
| echo "user_test_failed=$USER_FAILED" >> $GITHUB_OUTPUT | ||
| if [ $USER_FAILED -gt 0 ]; then | ||
| echo "" | ||
| echo "❌ Device Farm tests failed" | ||
| echo " Devices: $USER_PASSED passed, $USER_FAILED failed (out of $USER_TEST_COUNT)" | ||
| echo " Totals: $TOTAL_TOTAL | Passed: $TOTAL_PASSED | Failed: $TOTAL_FAILED | Skipped: $TOTAL_SKIPPED" | ||
| exit 1 | ||
| elif [ $USER_PASSED -eq 0 ] && [[ "$ALL_RESULTS_PASSED" != "true" ]]; then | ||
| echo "" | ||
| echo "❌ No devices ran tests (all runs skipped or errored)" | ||
| exit 1 | ||
| fi | ||
| echo "" | ||
| echo "✅ Device Farm tests passed!" | ||
| echo " Devices: $USER_PASSED passed (out of $USER_TEST_COUNT)" | ||
| if [ "$TOTAL_SKIPPED" -gt 0 ]; then | ||
| echo " ⚠️ $TOTAL_SKIPPED test(s) skipped (device unavailable)" | ||
| fi | ||
| echo " Totals: $TOTAL_TOTAL | Passed: $TOTAL_PASSED | Failed: $TOTAL_FAILED | Skipped: $TOTAL_SKIPPED" | ||
| - name: Refresh AWS credentials for log download | ||
| if: always() && steps.schedule_run.outputs.run_arn_1 | ||
| uses: aws-actions/configure-aws-credentials@8df5847569e6427dd6c4fb1cf565c83acfa8afa7 # 6.0.0 | ||
| with: | ||
| role-to-assume: ${{ secrets.AWS_OIDC_ROLE_ARN }} | ||
| aws-region: us-west-2 | ||
| role-session-name: device-farm-logs | ||
| - name: Download Device Farm Logs | ||
| if: always() && steps.schedule_run.outputs.run_arn_1 | ||
| run: | | ||
| RUN_ARN_1="${{ steps.schedule_run.outputs.run_arn_1 }}" | ||
| RUN_ARN_2="${{ steps.schedule_run.outputs.run_arn_2 }}" | ||
| RUN_ARN_3="${{ steps.schedule_run.outputs.run_arn_3 }}" | ||
| RUN_ARN_4="${{ steps.schedule_run.outputs.run_arn_4 }}" | ||
| RUN_ARN_5="${{ steps.schedule_run.outputs.run_arn_5 }}" | ||
| RUN_ARN_6="${{ steps.schedule_run.outputs.run_arn_6 }}" | ||
| RUN_ARN_7="${{ steps.schedule_run.outputs.run_arn_7 }}" | ||
| RUN_ARN_8="${{ steps.schedule_run.outputs.run_arn_8 }}" | ||
| RUN_ARN_9="${{ steps.schedule_run.outputs.run_arn_9 }}" | ||
| RUN_ARN_10="${{ steps.schedule_run.outputs.run_arn_10 }}" | ||
| RUN_COUNT="${{ steps.schedule_run.outputs.run_count }}" | ||
| LOG_DIR="devicefarm-logs/${{ matrix.platform }}" | ||
| PLATFORM="${{ matrix.platform }}" | ||
| mkdir -p "$LOG_DIR" | ||
| RUN_ARNS=("$RUN_ARN_1") | ||
| for i in $(seq 2 "$RUN_COUNT"); do | ||
| eval "arn=\$RUN_ARN_$i" | ||
| if [ -n "$arn" ]; then | ||
| RUN_ARNS+=("$arn") | ||
| fi | ||
| done | ||
| echo "" | ||
| echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" | ||
| echo "📥 DOWNLOADING DEVICE FARM LOGS" | ||
| echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" | ||
| echo "" | ||
| for RUN_ARN in "${RUN_ARNS[@]}"; do | ||
| RUN_DETAILS=$(aws devicefarm get-run --arn "$RUN_ARN" --output json 2>/dev/null || echo '{}') | ||
| RUN_LABEL=$(echo "$RUN_DETAILS" | jq -r '.run.name // "unknown"') | ||
| echo "" | ||
| echo "========================================" | ||
| echo "📦 Run: $RUN_LABEL" | ||
| echo "========================================" | ||
| SAFE_RUN=$(echo "$RUN_LABEL" | tr ' /' '__' | tr -cd '[:alnum:]_-') | ||
| JOBS=$(aws devicefarm list-jobs --arn "$RUN_ARN" --output json 2>/dev/null || echo '{"jobs":[]}') | ||
| for JOB_ARN in $(echo "$JOBS" | jq -r '.jobs[].arn'); do | ||
| DEVICE_NAME=$(echo "$JOBS" | jq -r --arg arn "$JOB_ARN" '.jobs[] | select(.arn == $arn) | .device.name // "unknown"') | ||
| JOB_RESULT=$(echo "$JOBS" | jq -r --arg arn "$JOB_ARN" '.jobs[] | select(.arn == $arn) | .result // "UNKNOWN"') | ||
| SAFE_NAME=$(echo "$DEVICE_NAME" | tr ' /' '__' | tr -cd '[:alnum:]_-') | ||
| echo "" | ||
| echo "📱 $DEVICE_NAME ($JOB_RESULT)" | ||
| SUITES=$(aws devicefarm list-suites --arn "$JOB_ARN" --output json 2>/dev/null || echo '{"suites":[]}') | ||
| for SUITE_ARN in $(echo "$SUITES" | jq -r '.suites[].arn'); do | ||
| SUITE_NAME=$(echo "$SUITES" | jq -r --arg arn "$SUITE_ARN" '.suites[] | select(.arn == $arn) | .name // "unknown"') | ||
| SAFE_SUITE=$(echo "$SUITE_NAME" | tr ' /' '__' | tr -cd '[:alnum:]_-') | ||
| ARTIFACTS=$(aws devicefarm list-artifacts --arn "$SUITE_ARN" --type FILE --output json 2>/dev/null || echo '{"artifacts":[]}') | ||
| echo "$ARTIFACTS" | jq -c '.artifacts[]' 2>/dev/null | while read -r ARTIFACT; do | ||
| ART_NAME=$(echo "$ARTIFACT" | jq -r '.name // "unknown"') | ||
| ART_URL=$(echo "$ARTIFACT" | jq -r '.url // empty') | ||
| ART_EXT=$(echo "$ARTIFACT" | jq -r '.extension // "txt"') | ||
| [ -z "$ART_URL" ] && continue | ||
| if [ "$PLATFORM" = "Android" ]; then | ||
| if echo "$ART_NAME" | grep -qiE "^video$" || echo "$ART_EXT" | grep -qiE "^mp4$"; then | ||
| echo " Skipped (video): $SUITE_NAME / $ART_NAME" | ||
| continue | ||
| fi | ||
| fi | ||
| SAFE_ART=$(echo "$ART_NAME" | tr ' /' '__' | tr -cd '[:alnum:]_-') | ||
| DEST="$LOG_DIR/${SAFE_RUN}_${SAFE_NAME}_${SAFE_SUITE}_${SAFE_ART}.${ART_EXT}" | ||
| if curl -fsSL --max-time 300 -o "$DEST" "$ART_URL" 2>/dev/null; then | ||
| echo " Downloaded: $SUITE_NAME / $ART_NAME" | ||
| if echo "$ART_NAME" | grep -qiE "test.spec|testspec"; then | ||
| echo ""; echo "::group::📋 [$DEVICE_NAME] $SUITE_NAME -- $ART_NAME" | ||
| cat "$DEST" 2>/dev/null || true; echo "::endgroup::" | ||
| fi | ||
| fi | ||
| done | ||
| LOG_ARTIFACTS=$(aws devicefarm list-artifacts --arn "$SUITE_ARN" --type LOG --output json 2>/dev/null || echo '{"artifacts":[]}') | ||
| echo "$LOG_ARTIFACTS" | jq -c '.artifacts[]' 2>/dev/null | while read -r ARTIFACT; do | ||
| ART_NAME=$(echo "$ARTIFACT" | jq -r '.name // "unknown"') | ||
| ART_URL=$(echo "$ARTIFACT" | jq -r '.url // empty') | ||
| ART_EXT=$(echo "$ARTIFACT" | jq -r '.extension // "txt"') | ||
| [ -z "$ART_URL" ] && continue | ||
| SAFE_ART=$(echo "$ART_NAME" | tr ' /' '__' | tr -cd '[:alnum:]_-') | ||
| DEST="$LOG_DIR/${SAFE_RUN}_${SAFE_NAME}_${SAFE_SUITE}_${SAFE_ART}.${ART_EXT}" | ||
| if curl -fsSL --max-time 300 -o "$DEST" "$ART_URL" 2>/dev/null; then | ||
| echo " Downloaded: $SUITE_NAME / $ART_NAME (LOG)" | ||
| fi | ||
| done | ||
| done | ||
| JOB_ARTIFACTS=$(aws devicefarm list-artifacts --arn "$JOB_ARN" --type FILE --output json 2>/dev/null || echo '{"artifacts":[]}') | ||
| echo "$JOB_ARTIFACTS" | jq -c '.artifacts[]' 2>/dev/null | while read -r ARTIFACT; do | ||
| ART_NAME=$(echo "$ARTIFACT" | jq -r '.name // "unknown"') | ||
| ART_URL=$(echo "$ARTIFACT" | jq -r '.url // empty') | ||
| ART_EXT=$(echo "$ARTIFACT" | jq -r '.extension // "txt"') | ||
| [ -z "$ART_URL" ] && continue | ||
| if [ "$PLATFORM" = "Android" ]; then | ||
| if echo "$ART_NAME" | grep -qiE "^video$" || echo "$ART_EXT" | grep -qiE "^mp4$"; then | ||
| echo " Skipped (video): job-level / $ART_NAME" | ||
| continue | ||
| fi | ||
| fi | ||
| SAFE_ART=$(echo "$ART_NAME" | tr ' /' '__' | tr -cd '[:alnum:]_-') | ||
| DEST="$LOG_DIR/${SAFE_RUN}_${SAFE_NAME}_job_${SAFE_ART}.${ART_EXT}" | ||
| if curl -fsSL --max-time 300 -o "$DEST" "$ART_URL" 2>/dev/null; then | ||
| echo " Downloaded (job-level): $ART_NAME" | ||
| fi | ||
| done | ||
| done | ||
| done | ||
| echo "" | ||
| echo "📦 All downloaded logs:" | ||
| find "$LOG_DIR" -type f -exec ls -lh {} \; 2>/dev/null || echo " (no logs downloaded)" | ||
| - name: Extract Console Logs | ||
| if: always() && steps.schedule_run.outputs.run_arn_1 | ||
| run: | | ||
| LOG_DIR="devicefarm-logs/${{ matrix.platform }}" | ||
| CONSOLE_DIR="console-logs/${{ matrix.platform }}" | ||
| mkdir -p "$CONSOLE_DIR" | ||
| for f in "$LOG_DIR"/*; do | ||
| [ -f "$f" ] || continue | ||
| fname=$(basename "$f") | ||
| if echo "$fname" | grep -qiE "Logcat\."; then | ||
| cp "$f" "$CONSOLE_DIR/" | ||
| echo " Console log: $fname" | ||
| fi | ||
| if echo "$fname" | grep -qiE "Customer_Artifacts\.zip$"; then | ||
| prefix=$(echo "$fname" | sed 's/_Customer_Artifacts\.zip$//') | ||
| tmpdir=$(mktemp -d) | ||
| if unzip -qo "$f" -d "$tmpdir" 2>/dev/null; then | ||
| bare_log=$(find "$tmpdir" -name "bare_console.log" -type f 2>/dev/null | head -1) | ||
| if [ -n "$bare_log" ]; then | ||
| cp "$bare_log" "$CONSOLE_DIR/${prefix}_bare_console.log" | ||
| echo " Extracted: ${prefix}_bare_console.log" | ||
| fi | ||
| appium_log=$(find "$tmpdir" -name "appium.log" -type f 2>/dev/null | head -1) | ||
| if [ -n "$appium_log" ]; then | ||
| cp "$appium_log" "$CONSOLE_DIR/${prefix}_appium.log" | ||
| echo " Extracted: ${prefix}_appium.log" | ||
| fi | ||
| else | ||
| echo " WARNING: Failed to unzip $fname" | ||
| fi | ||
| rm -rf "$tmpdir" | ||
| fi | ||
| done | ||
| echo "" | ||
| echo "📋 Console logs extracted:" | ||
| find "$CONSOLE_DIR" -type f -exec ls -lh {} \; 2>/dev/null || echo " (no console logs found)" | ||
| - name: Upload Console Logs | ||
| if: always() && steps.schedule_run.outputs.run_arn_1 | ||
| uses: actions/upload-artifact@v4 | ||
| with: | ||
| name: console-logs-${{ matrix.platform }} | ||
| path: console-logs/ | ||
| retention-days: 30 | ||
| if-no-files-found: ignore | ||
| - name: Upload Full Device Farm Logs | ||
| if: always() && steps.schedule_run.outputs.run_arn_1 | ||
| uses: actions/upload-artifact@v4 | ||
| with: | ||
| name: devicefarm-logs-${{ matrix.platform }} | ||
| path: devicefarm-logs/ | ||
| retention-days: 30 | ||
| if-no-files-found: ignore | ||