QA Android UI Tests #41
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: QA Android UI Tests | |
| on: | |
| workflow_dispatch: | |
| inputs: | |
| flavor: | |
| description: "App flavor." | |
| required: true | |
| type: choice | |
| options: | |
| - internal release candidate | |
| - internal beta | |
| - staging compat | |
| - experimental | |
| - column-1 | |
| - column-2 | |
| - column-3 | |
| - debug | |
| - fdroid | |
| - production | |
| default: internal release candidate | |
| appBuildNumber: | |
| description: "Use 'latest' OR a build number substring (e.g. 71389) OR an .apk key/filename" | |
| required: true | |
| default: "latest" | |
| type: string | |
| isUpgrade: | |
| description: "Upgrade test? If true, oldBuildNumber is REQUIRED." | |
| required: true | |
| default: false | |
| type: boolean | |
| oldBuildNumber: | |
| description: "For upgrade runs: old build number substring (or an .apk filename)" | |
| required: false | |
| default: "" | |
| type: string | |
| enforceAppInstall: | |
| description: "Allow downgrade install (-d). Use true if device has higher version." | |
| required: true | |
| default: false | |
| type: boolean | |
| TAGS: | |
| description: "Tags: '@regression' OR '@TC-8143'. (First non-empty wins.)" | |
| required: false | |
| default: "" | |
| type: string | |
| androidDeviceId: | |
| description: "Target a specific device. Leave empty to use ALL connected devices." | |
| required: false | |
| default: "" | |
| type: string | |
| permissions: | |
| contents: read | |
| jobs: | |
| validate-and-resolve-inputs: | |
| name: Validate + resolve selectors | |
| runs-on: ubuntu-latest | |
| outputs: | |
| resolvedTestCaseId: ${{ steps.resolve_selector.outputs.testCaseId }} | |
| resolvedCategory: ${{ steps.resolve_selector.outputs.category }} | |
| s3Folder: ${{ steps.resolve_flavor.outputs.s3Folder }} | |
| appId: ${{ steps.resolve_flavor.outputs.appId }} | |
| steps: | |
| - name: Validate upgrade inputs | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| if [[ "${{ inputs.isUpgrade }}" == "true" && -z "${{ inputs.oldBuildNumber }}" ]]; then | |
| echo "ERROR: oldBuildNumber is REQUIRED when isUpgrade=true" | |
| exit 1 | |
| fi | |
| - name: Resolve selector from TAGS | |
| id: resolve_selector | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| TESTCASE_ID="" | |
| CATEGORY="" | |
| TAGS_RAW="${{ inputs.TAGS }}" | |
| trim() { echo "$1" | xargs; } | |
| if [[ -n "$(trim "${TAGS_RAW}")" ]]; then | |
| sel="" | |
| IFS=',' read -ra parts <<< "${TAGS_RAW}" | |
| for p in "${parts[@]}"; do | |
| t="$(trim "$p")" | |
| if [[ -n "$t" ]]; then | |
| sel="$t" | |
| break | |
| fi | |
| done | |
| sel="${sel#@}" | |
| sel="$(trim "$sel")" | |
| if [[ "$sel" == *:* ]]; then | |
| echo "ERROR: TAGS format '@key:value' is not supported yet. Use '@TC-1234' or '@category'." | |
| exit 1 | |
| fi | |
| if [[ "$sel" =~ ^TC-[0-9]+$ ]]; then | |
| TESTCASE_ID="$sel" | |
| else | |
| CATEGORY="$sel" | |
| fi | |
| fi | |
| echo "testCaseId=$TESTCASE_ID" >> "$GITHUB_OUTPUT" | |
| echo "category=$CATEGORY" >> "$GITHUB_OUTPUT" | |
| - name: Resolve flavor -> S3_FOLDER + APP_ID | |
| id: resolve_flavor | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| FLAVOR="$(echo "${{ inputs.flavor }}" | xargs)" | |
| S3_FOLDER="" | |
| APP_ID="" | |
| case "$FLAVOR" in | |
| "staging compat") | |
| S3_FOLDER="artifacts/megazord/android/reloaded/staging/compat/" | |
| APP_ID="com.waz.zclient.dev" | |
| ;; | |
| "internal beta") | |
| S3_FOLDER="artifacts/megazord/android/reloaded/beta/release/" | |
| APP_ID="com.wire.android.internal" | |
| ;; | |
| "internal release candidate") | |
| S3_FOLDER="artifacts/megazord/android/reloaded/internal/compat/" | |
| APP_ID="com.wire.internal" | |
| ;; | |
| "experimental") | |
| S3_FOLDER="artifacts/megazord/android/reloaded/staging/compat/" | |
| APP_ID="com.waz.zclient.dev" | |
| ;; | |
| "column-1") | |
| S3_FOLDER="android/custom/bund/column1/prod/compatrelease/" | |
| APP_ID="com.wire.android.bund" | |
| ;; | |
| "column-2") | |
| S3_FOLDER="android/custom/bund/column2/prod/compatrelease/" | |
| APP_ID="com.wire.android.bund.column2" | |
| ;; | |
| "column-3") | |
| S3_FOLDER="android/custom/bund/column3/prod/compatrelease/" | |
| APP_ID="com.wire.android.bund.column3" | |
| ;; | |
| "debug") | |
| S3_FOLDER="artifacts/megazord/android/reloaded/dev/debug/" | |
| APP_ID="com.waz.zclient.dev.debug" | |
| ;; | |
| "fdroid") | |
| S3_FOLDER="artifacts/megazord/android/reloaded/fdroid/compatrelease/" | |
| APP_ID="com.wire" | |
| ;; | |
| "production") | |
| S3_FOLDER="artifacts/megazord/android/reloaded/prod/compatrelease/" | |
| APP_ID="com.wire" | |
| ;; | |
| *) | |
| echo "ERROR: Unknown flavor: '$FLAVOR'" | |
| exit 1 | |
| ;; | |
| esac | |
| echo "s3Folder=$S3_FOLDER" >> "$GITHUB_OUTPUT" | |
| echo "appId=$APP_ID" >> "$GITHUB_OUTPUT" | |
| - name: Print resolved values | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| echo "flavor=${{ inputs.flavor }}" | |
| echo "S3_FOLDER=${{ steps.resolve_flavor.outputs.s3Folder }}" | |
| echo "APP_ID=${{ steps.resolve_flavor.outputs.appId }}" | |
| echo "resolvedTestCaseId=${{ steps.resolve_selector.outputs.testCaseId }}" | |
| echo "resolvedCategory=${{ steps.resolve_selector.outputs.category }}" | |
| run-android-ui-tests: | |
| name: Run Android UI tests | |
| runs-on: | |
| - self-hosted | |
| - Linux | |
| - X64 | |
| - office | |
| - android-qa | |
| needs: validate-and-resolve-inputs | |
| env: | |
| AWS_REGION: eu-west-1 | |
| S3_BUCKET: ${{ secrets.AWS_S3_BUCKET }} | |
| OP_VAULT: "Test Automation" | |
| S3_FOLDER: ${{ needs.validate-and-resolve-inputs.outputs.s3Folder }} | |
| APP_ID: ${{ needs.validate-and-resolve-inputs.outputs.appId }} | |
| defaults: | |
| run: | |
| shell: bash | |
| steps: | |
| - name: Checkout (with submodules) | |
| uses: actions/checkout@v4 | |
| with: | |
| clean: true | |
| submodules: recursive | |
| - name: Set up Java 17 | |
| uses: actions/setup-java@v4 | |
| with: | |
| distribution: temurin | |
| java-version: "17" | |
| cache: gradle | |
| - name: Set up Android SDK (ANDROID_HOME + adb) | |
| uses: android-actions/setup-android@v3 | |
| - name: Ensure required tools exist | |
| run: | | |
| set -euo pipefail | |
| command -v adb >/dev/null 2>&1 || { echo "ERROR: adb not found"; exit 1; } | |
| command -v python3 >/dev/null 2>&1 || { echo "ERROR: python3 not found on this runner"; exit 1; } | |
| if command -v aws >/dev/null 2>&1; then | |
| aws --version | |
| exit 0 | |
| fi | |
| command -v curl >/dev/null 2>&1 || { echo "ERROR: curl not found"; exit 1; } | |
| command -v unzip >/dev/null 2>&1 || { echo "ERROR: unzip not found"; exit 1; } | |
| : "${RUNNER_TEMP:?RUNNER_TEMP not set}" | |
| echo "aws CLI not found. Installing AWS CLI v2 locally..." | |
| AWS_ROOT="${RUNNER_TEMP}/awscli" | |
| ZIP_PATH="${RUNNER_TEMP}/awscliv2.zip" | |
| rm -rf "${AWS_ROOT}" "${ZIP_PATH}" "${RUNNER_TEMP}/aws" | |
| mkdir -p "${AWS_ROOT}" | |
| curl -fsSL -o "${ZIP_PATH}" "https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip" | |
| unzip -oq "${ZIP_PATH}" -d "${RUNNER_TEMP}" | |
| rm -f "${ZIP_PATH}" | |
| "${RUNNER_TEMP}/aws/install" -i "${AWS_ROOT}" -b "${AWS_ROOT}/bin" | |
| echo "${AWS_ROOT}/bin" >> "${GITHUB_PATH}" | |
| export PATH="${AWS_ROOT}/bin:${PATH}" | |
| aws --version | |
| - name: Configure AWS credentials (for S3) | |
| uses: aws-actions/configure-aws-credentials@v4 | |
| with: | |
| aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} | |
| aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} | |
| aws-region: eu-west-1 | |
| - name: Download APK(s) from S3 | |
| id: download_apks | |
| env: | |
| APP_BUILD_NUMBER: ${{ inputs.appBuildNumber }} | |
| IS_UPGRADE: ${{ inputs.isUpgrade }} | |
| OLD_BUILD_NUMBER: ${{ inputs.oldBuildNumber }} | |
| run: | | |
| set -euo pipefail | |
| : "${S3_BUCKET:?ERROR: Missing secret AWS_S3_BUCKET}" | |
| : "${S3_FOLDER:?ERROR: S3_FOLDER missing}" | |
| echo "S3 prefix: s3://${S3_BUCKET}/${S3_FOLDER}" | |
| aws s3api list-objects-v2 \ | |
| --bucket "${S3_BUCKET}" \ | |
| --prefix "${S3_FOLDER}" \ | |
| --query "Contents[?ends_with(Key, '.apk')].Key" \ | |
| --output json > "${RUNNER_TEMP}/apk_keys.json" | |
| python3 - <<'PY' | tee "${RUNNER_TEMP}/apk_env.txt" | |
| import json, os, re, sys | |
| keys_path = os.path.join(os.environ["RUNNER_TEMP"], "apk_keys.json") | |
| try: | |
| data = json.load(open(keys_path, "r", encoding="utf-8")) | |
| except Exception: | |
| data = [] | |
| if not isinstance(data, list): | |
| data = [] | |
| apks = [k for k in data if isinstance(k, str) and k.lower().endswith(".apk")] | |
| if not apks: | |
| print("ERROR: No .apk files found in this prefix.", file=sys.stderr) | |
| sys.exit(1) | |
| app_build = (os.environ.get("APP_BUILD_NUMBER") or "").strip() | |
| is_upgrade = (os.environ.get("IS_UPGRADE", "false").strip().lower() == "true") | |
| old_input = (os.environ.get("OLD_BUILD_NUMBER") or "").strip() | |
| def parse_version(fname: str): | |
| m = re.search(r"-v(\d+)\.(\d+)\.(\d+)-(\d+)", fname) | |
| if m: | |
| return (int(m.group(1)), int(m.group(2)), int(m.group(3)), int(m.group(4))) | |
| m = re.search(r"-v(\d+)\.(\d+)\.(\d+)-fdroid", fname) | |
| if m: | |
| return (int(m.group(1)), int(m.group(2)), int(m.group(3)), 0) | |
| m = re.search(r"-v(\d+)\.(\d+)\.(\d+)", fname) | |
| if m: | |
| return (int(m.group(1)), int(m.group(2)), int(m.group(3)), 0) | |
| return None | |
| def build_label(fname: str): | |
| m = re.search(r"-v(\d+\.\d+\.\d+-\d+)", fname) | |
| if m: return m.group(1) | |
| m = re.search(r"-v(\d+\.\d+\.\d+)-fdroid", fname) | |
| if m: return m.group(1) | |
| m = re.search(r"-v(\d+\.\d+\.\d+)", fname) | |
| if m: return m.group(1) | |
| return "" | |
| def pick_by_substring(substr: str): | |
| if not substr: | |
| return None | |
| for k in apks: | |
| if substr in k.split("/")[-1]: | |
| return k | |
| return None | |
| def pick_by_filename(filename: str): | |
| if not filename: | |
| return None | |
| for k in apks: | |
| if k.split("/")[-1] == filename: | |
| return k | |
| return None | |
| parsed = [] | |
| for k in apks: | |
| pv = parse_version(k.split("/")[-1]) | |
| if pv is not None: | |
| parsed.append((pv, k)) | |
| parsed.sort(key=lambda x: x[0]) | |
| latest_key = parsed[-1][1] if parsed else apks[-1] | |
| second_latest_key = parsed[-2][1] if len(parsed) >= 2 else None | |
| def normalize_direct(s: str): | |
| s = s.strip() | |
| if s.startswith("s3://"): | |
| # s3://bucket/key... | |
| parts = s.split("/", 3) | |
| return parts[3] if len(parts) >= 4 else "" | |
| return s.lstrip("/") | |
| new_key = None | |
| old_key = None | |
| # Direct .apk input: allow s3://..., key..., or filename.apk | |
| if app_build.lower().endswith(".apk"): | |
| direct = normalize_direct(app_build) | |
| if "/" in direct: | |
| new_key = direct | |
| else: | |
| new_key = pick_by_filename(direct) or pick_by_substring(direct) | |
| if is_upgrade: | |
| if old_input.lower().endswith(".apk"): | |
| od = normalize_direct(old_input) | |
| old_key = (od if "/" in od else (pick_by_filename(od) or pick_by_substring(od))) | |
| else: | |
| old_key = pick_by_substring(old_input) if old_input else second_latest_key | |
| elif app_build == "latest": | |
| new_key = latest_key | |
| if is_upgrade: | |
| old_key = pick_by_substring(old_input) if old_input else second_latest_key | |
| else: | |
| new_key = pick_by_substring(app_build) | |
| if is_upgrade: | |
| if not old_input: | |
| print("ERROR: isUpgrade=true but oldBuildNumber is empty.", file=sys.stderr) | |
| sys.exit(1) | |
| old_key = pick_by_substring(old_input) | |
| if not new_key: | |
| print(f"ERROR: Could not resolve NEW apk for appBuildNumber='{app_build}'", file=sys.stderr) | |
| sys.exit(1) | |
| if is_upgrade and not old_key: | |
| print("ERROR: Upgrade requested but OLD apk could not be resolved.", file=sys.stderr) | |
| sys.exit(1) | |
| new_name = new_key.split("/")[-1] | |
| old_name = old_key.split("/")[-1] if old_key else "" | |
| print(f"NEW_S3_KEY={new_key}") | |
| print(f"OLD_S3_KEY={old_key or ''}") | |
| print(f"NEW_APK_NAME={new_name}") | |
| print(f"OLD_APK_NAME={old_name}") | |
| print(f"REAL_BUILD_NUMBER={build_label(new_name)}") | |
| print(f"OLD_BUILD_NUMBER={build_label(old_name) if old_name else ''}") | |
| PY | |
| # Make vars available now + future steps | |
| set -a | |
| source "${RUNNER_TEMP}/apk_env.txt" | |
| set +a | |
| cat "${RUNNER_TEMP}/apk_env.txt" >> "$GITHUB_ENV" | |
| cat "${RUNNER_TEMP}/apk_env.txt" >> "$GITHUB_OUTPUT" | |
| NEW_APK_PATH="${RUNNER_TEMP}/Wire.apk" | |
| echo "NEW_APK_PATH=${NEW_APK_PATH}" >> "$GITHUB_ENV" | |
| aws s3 cp "s3://${S3_BUCKET}/${NEW_S3_KEY}" "${NEW_APK_PATH}" --only-show-errors | |
| test -s "${NEW_APK_PATH}" | |
| if [[ "${IS_UPGRADE}" == "true" ]]; then | |
| OLD_APK_PATH="${RUNNER_TEMP}/Wire.old.apk" | |
| echo "OLD_APK_PATH=${OLD_APK_PATH}" >> "$GITHUB_ENV" | |
| aws s3 cp "s3://${S3_BUCKET}/${OLD_S3_KEY}" "${OLD_APK_PATH}" --only-show-errors | |
| test -s "${OLD_APK_PATH}" | |
| fi | |
| echo "Selected NEW=${NEW_APK_NAME}" | |
| if [[ "${IS_UPGRADE}" == "true" ]]; then | |
| echo "Selected OLD=${OLD_APK_NAME}" | |
| fi | |
| - name: Detect target device(s) | |
| run: | | |
| set -euo pipefail | |
| DEVICE_LINES="$(adb devices | awk 'NR>1 && $2=="device"{print $1}')" | |
| if [[ -z "${DEVICE_LINES}" ]]; then | |
| echo "ERROR: No online Android devices found." | |
| exit 1 | |
| fi | |
| TARGET="${{ inputs.androidDeviceId }}" | |
| if [[ -n "$TARGET" ]]; then | |
| if ! printf '%s\n' "$DEVICE_LINES" | grep -qx "$TARGET"; then | |
| echo "ERROR: androidDeviceId '$TARGET' not found in adb devices." | |
| exit 1 | |
| fi | |
| DEVICE_LIST="$TARGET" | |
| else | |
| DEVICE_LIST="$(printf '%s\n' "$DEVICE_LINES" | xargs)" | |
| fi | |
| DEVICE_COUNT="$(wc -w <<<"${DEVICE_LIST}" | tr -d ' ')" | |
| echo "DEVICE_LIST=${DEVICE_LIST}" >> "$GITHUB_ENV" | |
| echo "DEVICE_COUNT=${DEVICE_COUNT}" >> "$GITHUB_ENV" | |
| echo "Using device(s): ${DEVICE_LIST}" | |
| - name: Install APK(s) on device(s) | |
| run: | | |
| set -euo pipefail | |
| : "${DEVICE_LIST:?DEVICE_LIST missing}" | |
| : "${APP_ID:?APP_ID missing}" | |
| : "${NEW_APK_PATH:?NEW_APK_PATH missing}" | |
| INSTALL_FLAGS="-r" | |
| if [[ "${{ inputs.enforceAppInstall }}" == "true" ]]; then | |
| INSTALL_FLAGS="-r -d" | |
| fi | |
| PACKAGES=( | |
| "com.wire" | |
| "com.waz.zclient.dev" | |
| "com.wire.internal" | |
| "com.wire.android.internal" | |
| "com.wire.android.bund" | |
| "com.wire.android.bund.column2" | |
| "com.wire.android.bund.column3" | |
| "com.waz.zclient.dev.debug" | |
| ) | |
| read -ra DEVICES <<< "${DEVICE_LIST}" | |
| for SERIAL in "${DEVICES[@]}"; do | |
| ADB="adb -s ${SERIAL}" | |
| ${ADB} wait-for-device | |
| # Clean baseline: uninstall known package ids if present | |
| INSTALLED="$(${ADB} shell pm list packages || true)" | |
| for pkg in "${PACKAGES[@]}"; do | |
| if echo "${INSTALLED}" | grep -qx "package:${pkg}"; then | |
| ${ADB} uninstall "${pkg}" || true | |
| fi | |
| done | |
| if [[ "${{ inputs.isUpgrade }}" == "true" ]]; then | |
| : "${OLD_APK_PATH:?OLD_APK_PATH missing for upgrade}" | |
| ${ADB} install ${INSTALL_FLAGS} "${OLD_APK_PATH}" | |
| fi | |
| ${ADB} install ${INSTALL_FLAGS} "${NEW_APK_PATH}" | |
| if ! ${ADB} shell pm list packages | grep -qx "package:${APP_ID}"; then | |
| echo "ERROR: '${APP_ID}' not installed on ${SERIAL}." | |
| exit 1 | |
| fi | |
| done | |
| - name: Install 1Password CLI | |
| uses: 1password/install-cli-action@v2 | |
| - name: Fetch secrets.json (runtime only) | |
| env: | |
| OP_SERVICE_ACCOUNT_TOKEN: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }} | |
| run: | | |
| set -euo pipefail | |
| if [[ -z "${OP_SERVICE_ACCOUNT_TOKEN:-}" ]]; then | |
| echo "ERROR: Missing OP_SERVICE_ACCOUNT_TOKEN secret" | |
| exit 1 | |
| fi | |
| chmod +x ./gradlew | |
| SECRETS_FILE="${RUNNER_TEMP}/secrets.json" | |
| export SECRETS_JSON_PATH="${SECRETS_FILE}" | |
| echo "SECRETS_JSON_PATH=${SECRETS_FILE}" >> "$GITHUB_ENV" | |
| ./gradlew :tests:testsSupport:fetchSecrets \ | |
| --no-daemon \ | |
| --no-configuration-cache \ | |
| -PsecretsJsonPath="${SECRETS_FILE}" \ | |
| -PopVault="${OP_VAULT}" | |
| test -s "${SECRETS_FILE}" | |
| chmod 600 "${SECRETS_FILE}" | |
| echo "OK: secrets.json created at ${SECRETS_FILE}" | |
| - name: Build test APK (assemble) | |
| run: | | |
| set -euo pipefail | |
| ./gradlew :tests:testsCore:assembleDebugAndroidTest --no-daemon --no-configuration-cache | |
| - name: Run UI tests (one shard per device) | |
| env: | |
| RESOLVED_TESTCASE_ID: ${{ needs.validate-and-resolve-inputs.outputs.resolvedTestCaseId }} | |
| RESOLVED_CATEGORY: ${{ needs.validate-and-resolve-inputs.outputs.resolvedCategory }} | |
| run: | | |
| set -euo pipefail | |
| : "${DEVICE_LIST:?DEVICE_LIST missing}" | |
| : "${DEVICE_COUNT:?DEVICE_COUNT missing}" | |
| GRADLE_ARGS=( | |
| ":tests:testsCore:connectedDebugAndroidTest" | |
| "--no-daemon" | |
| "--no-configuration-cache" | |
| ) | |
| if [[ -n "${RESOLVED_TESTCASE_ID}" ]]; then | |
| GRADLE_ARGS+=("-Pandroid.testInstrumentationRunnerArguments.testCaseId=${RESOLVED_TESTCASE_ID}") | |
| fi | |
| if [[ -n "${RESOLVED_CATEGORY}" ]]; then | |
| GRADLE_ARGS+=("-Pandroid.testInstrumentationRunnerArguments.category=${RESOLVED_CATEGORY}") | |
| fi | |
| NUM_SHARDS="${DEVICE_COUNT}" | |
| if [[ -n "${RESOLVED_TESTCASE_ID}" ]]; then | |
| NUM_SHARDS="1" | |
| fi | |
| read -ra DEVICES <<< "${DEVICE_LIST}" | |
| echo "Sharding: numShards=${NUM_SHARDS}, devices=${DEVICE_LIST}" | |
| pids=() | |
| shard_index=0 | |
| for SERIAL in "${DEVICES[@]}"; do | |
| ( | |
| set -euo pipefail | |
| export ANDROID_SERIAL="${SERIAL}" | |
| export GRADLE_USER_HOME="${RUNNER_TEMP}/gradle-${SERIAL}" | |
| ./gradlew "${GRADLE_ARGS[@]}" \ | |
| "-Pandroid.testInstrumentationRunnerArguments.numShards=${NUM_SHARDS}" \ | |
| "-Pandroid.testInstrumentationRunnerArguments.shardIndex=${shard_index}" | |
| ) & | |
| pids+=("$!") | |
| shard_index=$((shard_index + 1)) | |
| done | |
| failed=0 | |
| for pid in "${pids[@]}"; do | |
| if ! wait "$pid"; then | |
| failed=1 | |
| fi | |
| done | |
| if [[ "$failed" -ne 0 ]]; then | |
| echo "ERROR: One or more shards failed." | |
| exit 1 | |
| fi | |
| - name: Pull Allure results from device(s) | |
| if: always() | |
| run: | | |
| set -euo pipefail | |
| if [[ -z "${DEVICE_LIST:-}" ]]; then | |
| echo "No devices detected (skipping allure pull)" | |
| exit 0 | |
| fi | |
| OUT_DIR="${RUNNER_TEMP}/allure-results" | |
| mkdir -p "${OUT_DIR}" | |
| read -ra DEVICES <<< "${DEVICE_LIST}" | |
| for SERIAL in "${DEVICES[@]}"; do | |
| echo "Pulling allure-results from ${SERIAL}..." | |
| mkdir -p "${OUT_DIR}/${SERIAL}" | |
| adb -s "${SERIAL}" pull "/sdcard/googletest/test_outputfiles/allure-results" "${OUT_DIR}/${SERIAL}" >/dev/null 2>&1 || true | |
| done | |
| - name: Merge Allure results (add device label) | |
| if: always() | |
| env: | |
| OUT_DIR: ${{ runner.temp }}/allure-results | |
| MERGED_DIR: ${{ runner.temp }}/allure-results-merged | |
| run: | | |
| set -euo pipefail | |
| python3 - <<'PY' | |
| import json | |
| import os | |
| import shutil | |
| import subprocess | |
| from pathlib import Path | |
| out_dir = Path(os.environ["OUT_DIR"]) | |
| merged_dir = Path(os.environ["MERGED_DIR"]) | |
| merged_dir.mkdir(parents=True, exist_ok=True) | |
| def get_prop(serial: str, prop: str) -> str: | |
| try: | |
| result = subprocess.run( | |
| ["adb", "-s", serial, "shell", "getprop", prop], | |
| check=False, | |
| capture_output=True, | |
| text=True, | |
| timeout=5, | |
| ) | |
| return result.stdout.strip() | |
| except Exception: | |
| return "" | |
| device_dirs = [p for p in out_dir.iterdir() if p.is_dir()] | |
| device_info = {} | |
| for device_dir in device_dirs: | |
| serial = device_dir.name | |
| model = get_prop(serial, "ro.product.model") or "unknown" | |
| sdk = get_prop(serial, "ro.build.version.release") or get_prop(serial, "ro.build.version.sdk") or "unknown" | |
| device_info[serial] = {"model": model, "sdk": sdk} | |
| def device_label(serial: str) -> str: | |
| meta = device_info.get(serial, {}) | |
| model = meta.get("model") or "unknown" | |
| sdk = meta.get("sdk") or "unknown" | |
| return f"{model} - {sdk} ({serial})" | |
| def add_label(data: dict, name: str, value: str) -> dict: | |
| labels = [l for l in data.get("labels", []) if l.get("name") != name] | |
| labels.append({"name": name, "value": value}) | |
| data["labels"] = labels | |
| return data | |
| for device_dir in device_dirs: | |
| serial = device_dir.name | |
| src_dir = device_dir / "allure-results" | |
| if not src_dir.is_dir(): | |
| src_dir = device_dir | |
| if not src_dir.is_dir(): | |
| continue | |
| label = device_label(serial) | |
| for item in src_dir.iterdir(): | |
| if item.is_dir(): | |
| continue | |
| if item.name in ("executor.json", "environment.properties"): | |
| continue | |
| if item.name.endswith("-result.json"): | |
| try: | |
| data = json.loads(item.read_text(encoding="utf-8")) | |
| except Exception: | |
| continue | |
| data = add_label(data, "device", label) | |
| (merged_dir / item.name).write_text( | |
| json.dumps(data, ensure_ascii=True), | |
| encoding="utf-8", | |
| ) | |
| else: | |
| shutil.copy2(item, merged_dir / item.name) | |
| if device_info: | |
| devices = ", ".join(device_label(serial) for serial in sorted(device_info.keys())) | |
| (merged_dir / "environment.properties").write_text( | |
| f"devices={devices}\n", encoding="utf-8" | |
| ) | |
| run_id = os.environ.get("GITHUB_RUN_ID", "") | |
| repo = os.environ.get("GITHUB_REPOSITORY", "") | |
| server = os.environ.get("GITHUB_SERVER_URL", "https://github.com") | |
| run_url = f"{server}/{repo}/actions/runs/{run_id}" if repo and run_id else "" | |
| executor = { | |
| "name": "GitHub Actions", | |
| "type": "github", | |
| "url": run_url, | |
| "buildName": os.environ.get("GITHUB_RUN_NUMBER", ""), | |
| "buildUrl": run_url, | |
| "reportName": "Android UI Tests", | |
| } | |
| (merged_dir / "executor.json").write_text( | |
| json.dumps(executor, ensure_ascii=True), | |
| encoding="utf-8", | |
| ) | |
| PY | |
| - name: Generate Allure HTML report | |
| if: always() | |
| env: | |
| MERGED_DIR: ${{ runner.temp }}/allure-results-merged | |
| REPORT_DIR: ${{ runner.temp }}/allure-report | |
| run: | | |
| set -euo pipefail | |
| if [[ ! -d "${MERGED_DIR}" ]]; then | |
| echo "No merged Allure results found" | |
| exit 0 | |
| fi | |
| ALLURE_VERSION="2.29.0" | |
| ALLURE_TGZ="${RUNNER_TEMP}/allure-${ALLURE_VERSION}.tgz" | |
| curl -fsSL -o "${ALLURE_TGZ}" \ | |
| "https://github.com/allure-framework/allure2/releases/download/${ALLURE_VERSION}/allure-${ALLURE_VERSION}.tgz" | |
| tar -xzf "${ALLURE_TGZ}" -C "${RUNNER_TEMP}" | |
| "${RUNNER_TEMP}/allure-${ALLURE_VERSION}/bin/allure" \ | |
| generate "${MERGED_DIR}" -o "${REPORT_DIR}" --clean | |
| - name: Upload test artifacts | |
| if: always() | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: android-ui-test-results | |
| if-no-files-found: warn | |
| retention-days: 90 | |
| path: | | |
| tests/testsCore/build/reports/androidTests/connected | |
| ${{ runner.temp }}/allure-results-merged | |
| ${{ runner.temp }}/allure-report | |
| - name: Cleanup (remove secrets + build outputs) | |
| if: always() | |
| run: | | |
| set -euo pipefail | |
| rm -f "${RUNNER_TEMP}/secrets.json" || true | |
| rm -f "${RUNNER_TEMP}/Wire.apk" "${RUNNER_TEMP}/Wire.old.apk" || true | |
| rm -rf "${RUNNER_TEMP}/allure-results" || true | |
| rm -rf "${RUNNER_TEMP}/allure-results-merged" || true | |
| rm -rf "${RUNNER_TEMP}/allure-report" || true | |
| # Remove build outputs (these can contain BuildConfig secrets) but keep Gradle caches | |
| git clean -ffdx -e .gradle -e .kotlin |