diff --git a/.github/workflows/qa-android-ui-tests.yml b/.github/workflows/qa-android-ui-tests.yml index 857661c5ad9..82812542e79 100644 --- a/.github/workflows/qa-android-ui-tests.yml +++ b/.github/workflows/qa-android-ui-tests.yml @@ -1,5 +1,12 @@ name: QA Android UI Tests +# Concurrency lock: +# - If androidDeviceId is set, we lock per-device so only one run can use that specific phone at a time (other devices can run in parallel). +# - If androidDeviceId is empty ("auto"), we lock the shared device pool so only one auto-run uses the farm at a time (other auto-runs queue). +concurrency: + group: qa-android-ui-tests-office-${{ inputs.androidDeviceId || 'auto' }} + cancel-in-progress: false + on: workflow_dispatch: inputs: @@ -44,34 +51,12 @@ on: - production default: internal release candidate - buildType: - description: "Build type" - required: true - type: choice - options: - - release - - debug - - compat - default: release - TAGS: description: "Tags: '@regression' OR '@TC-8143'." required: false default: "" type: string - branch: - description: "Branch" - required: true - default: "develop" - type: string - - backendType: - description: "Backend." - required: true - default: "staging" - type: string - testinyRunName: description: "TESTINY_RUN_NAME." required: false @@ -84,145 +69,216 @@ on: default: "" type: string - callingServiceEnv: - description: "Calling service environment." - required: true - type: choice - options: - - dev - - custom - - master - - avs - - qa - - edge - - staging - - prod - default: dev - - callingServiceUrl: - description: "Calling service URL." - required: true - default: "loadbalanced" - type: string - - deflakeCount: - description: "Rerun only failed tests." - required: true - default: 1 - type: number +permissions: + contents: read jobs: + # Validate user inputs and derive selectors. validate-and-resolve-inputs: - name: Validate + resolve selectors (no execution) + name: Validate + resolve selectors runs-on: ubuntu-latest outputs: - resolvedTestCaseId: ${{ steps.resolve.outputs.testCaseId }} - resolvedCategory: ${{ steps.resolve.outputs.category }} - resolvedTagKey: ${{ steps.resolve.outputs.tagKey }} - resolvedTagValue: ${{ steps.resolve.outputs.tagValue }} + resolvedTestCaseId: ${{ steps.resolve_selector.outputs.testCaseId }} + resolvedCategory: ${{ steps.resolve_selector.outputs.category }} steps: + - name: Checkout repository + uses: actions/checkout@v4 + + # Validate upgrade inputs before runner work starts. - 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 + env: + IS_UPGRADE: ${{ inputs.isUpgrade }} + OLD_BUILD_NUMBER: ${{ inputs.oldBuildNumber }} + run: bash scripts/qa_android_ui_tests/validation.sh validate-upgrade-inputs + # Resolve TAGS into CI selectors and expose them as job outputs. - name: Resolve selector from TAGS - id: resolve + id: resolve_selector shell: bash - run: | - set -euo pipefail - - # We only use TAGS in this UI (no extra selector inputs) - TESTCASE_ID="" - CATEGORY="" - TAG_KEY="" - TAG_VALUE="" - - TAGS_RAW="${{ inputs.TAGS }}" - - trim() { echo "$1" | xargs; } - - # If TAGS is provided, derive selector - if [[ -n "$(trim "${TAGS_RAW}")" ]]; then - # TAGS can be comma-separated; use first non-empty token - sel="" - IFS=',' read -ra parts <<< "${TAGS_RAW}" - for p in "${parts[@]}"; do - t="$(trim "$p")" - if [[ -n "$t" ]]; then - sel="$t" - break - fi - done - - # Strip leading @ if present - sel="${sel#@}" - sel="$(trim "$sel")" - - # @TC-8143 -> testCaseId - if [[ "$sel" =~ ^TC-[0-9]+$ ]]; then - TESTCASE_ID="$sel" - - # @key:value -> tagKey/tagValue (kept for future parity) - elif [[ "$sel" == *:* ]]; then - k="$(trim "${sel%%:*}")" - v="$(trim "${sel#*:}")" - if [[ -z "$k" || -z "$v" ]]; then - echo "ERROR: Invalid TAGS format '${TAGS_RAW}'. Expected '@key:value' e.g. @criticalFlow:groupCallChat" - exit 1 - fi - TAG_KEY="$k" - TAG_VALUE="$v" - - # @regression -> category - else - CATEGORY="$sel" - fi - fi - - # Safety: prevent partial key/value - if [[ -n "$TAG_KEY" && -z "$TAG_VALUE" ]]; then - echo "ERROR: tagKey provided but tagValue is empty." - exit 1 - fi - if [[ -z "$TAG_KEY" && -n "$TAG_VALUE" ]]; then - echo "ERROR: tagValue provided but tagKey is empty." - exit 1 - fi - - echo "testCaseId=$TESTCASE_ID" >> "$GITHUB_OUTPUT" - echo "category=$CATEGORY" >> "$GITHUB_OUTPUT" - echo "tagKey=$TAG_KEY" >> "$GITHUB_OUTPUT" - echo "tagValue=$TAG_VALUE" >> "$GITHUB_OUTPUT" - - - name: Print final resolved inputs (for reviewers) + env: + TAGS_RAW: ${{ inputs.TAGS }} + run: bash scripts/qa_android_ui_tests/validation.sh resolve-selector-from-tags + + # Print resolved values for traceability in workflow logs. + - name: Print resolved values shell: bash - run: | - set -euo pipefail - echo "=== RAW INPUTS ===" - echo "appBuildNumber=${{ inputs.appBuildNumber }}" - echo "isUpgrade=${{ inputs.isUpgrade }}" - echo "oldBuildNumber=${{ inputs.oldBuildNumber }}" - echo "enforceAppInstall=${{ inputs.enforceAppInstall }}" - echo "flavor=${{ inputs.flavor }}" - echo "buildType=${{ inputs.buildType }}" - echo "TAGS=${{ inputs.TAGS }}" - echo "branch=${{ inputs.branch }}" - echo "backendType=${{ inputs.backendType }}" - echo "testinyRunName=${{ inputs.testinyRunName }}" - echo "androidDeviceId=${{ inputs.androidDeviceId }}" - echo "callingServiceEnv=${{ inputs.callingServiceEnv }}" - echo "callingServiceUrl=${{ inputs.callingServiceUrl }}" - echo "deflakeCount=${{ inputs.deflakeCount }}" - echo "" - echo "=== RESOLVED SELECTORS ===" - echo "testCaseId=${{ steps.resolve.outputs.testCaseId }}" - echo "category=${{ steps.resolve.outputs.category }}" - echo "tagKey=${{ steps.resolve.outputs.tagKey }}" - echo "tagValue=${{ steps.resolve.outputs.tagValue }}" + env: + FLAVOR_INPUT: ${{ inputs.flavor }} + RESOLVED_TESTCASE_ID: ${{ steps.resolve_selector.outputs.testCaseId }} + RESOLVED_CATEGORY: ${{ steps.resolve_selector.outputs.category }} + run: bash scripts/qa_android_ui_tests/validation.sh print-resolved-values + + run-android-ui-tests: + name: Run Android UI tests + runs-on: + - self-hosted + - Linux + - X64 + - office + - android-qa + + needs: validate-and-resolve-inputs + permissions: + contents: write + + env: + AWS_REGION: eu-west-1 + S3_BUCKET: ${{ secrets.AWS_S3_BUCKET }} + OP_VAULT: "Test Automation" + FLAVORS_CONFIG_PATH: "/etc/android-qa/flavors.json" + + 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 + + # Verify required CLIs on the runner before setup continues. + - name: Ensure required tools exist + run: bash scripts/qa_android_ui_tests/execution_setup.sh ensure-required-tools + + # Flavor resolution is runner-driven (from /etc/android-qa/flavors.json), not hardcoded in repo. + # This bash subcommand exports S3_FOLDER, APP_ID, and PACKAGES_TO_UNINSTALL for downstream steps. + - name: Resolve flavor (runner config) + id: resolve_flavor + env: + FLAVOR_INPUT: ${{ inputs.flavor }} + run: bash scripts/qa_android_ui_tests/execution_setup.sh resolve-flavor + + - 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 + + # Download app APK(s) from S3 and export resolved build metadata. + - 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: bash scripts/qa_android_ui_tests/execution_setup.sh download-apks + + # Select device(s): use input device when provided, otherwise auto-pick. + - name: Detect target device(s) + env: + TARGET_DEVICE_ID: ${{ inputs.androidDeviceId }} + RESOLVED_TESTCASE_ID: ${{ needs.validate-and-resolve-inputs.outputs.resolvedTestCaseId }} + run: bash scripts/qa_android_ui_tests/execution_setup.sh detect-target-devices + + # Install app/test prerequisites on each selected device. + - name: Install APK(s) on device(s) + env: + ENFORCE_APP_INSTALL: ${{ inputs.enforceAppInstall }} + IS_UPGRADE: ${{ inputs.isUpgrade }} + run: bash scripts/qa_android_ui_tests/execution_setup.sh install-apks-on-devices + + - name: Install 1Password CLI + uses: 1password/install-cli-action@v2 + + # Fetch runtime secrets only for this run. + - name: Fetch secrets.json (runtime only) + env: + OP_SERVICE_ACCOUNT_TOKEN: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }} + run: bash scripts/qa_android_ui_tests/execution_setup.sh fetch-runtime-secrets + + # Build androidTest APK once and reuse it across selected devices. + - name: Build test APK (assemble once) + run: bash scripts/qa_android_ui_tests/execution_setup.sh build-test-apk + + # Resolve the built androidTest APK path for execution steps. + - name: Resolve test APK path + run: bash scripts/qa_android_ui_tests/execution_setup.sh resolve-test-apk-path + + # Resolve AndroidX Test Services APKs required for TestStorage/Allure. + - name: Resolve AndroidX Test Services APKs (for Allure TestStorage) + run: bash scripts/qa_android_ui_tests/execution_setup.sh resolve-test-services-apks + + # Run instrumentation on selected devices and stream per-device logs. + - name: Run UI tests (one shard per device, adb instrumentation) + env: + RESOLVED_TESTCASE_ID: ${{ needs.validate-and-resolve-inputs.outputs.resolvedTestCaseId }} + RESOLVED_CATEGORY: ${{ needs.validate-and-resolve-inputs.outputs.resolvedCategory }} + IS_UPGRADE: ${{ inputs.isUpgrade }} + run: bash scripts/qa_android_ui_tests/run_ui_tests.sh + + # Remove runtime secrets before report generation and publish steps. + - name: Remove runtime secrets (before Allure/Pages) + if: always() + run: bash scripts/qa_android_ui_tests/reporting.sh remove-runtime-secrets + + # Pull raw allure-results from each device even when tests fail. + - name: Pull Allure results from device(s) + if: always() + env: + OUT_DIR: ${{ runner.temp }}/allure-results + run: bash scripts/qa_android_ui_tests/reporting.sh pull-allure-results + + # Merge per-device results and attach run metadata labels. + - name: Merge Allure results (add device label) + if: always() + env: + OUT_DIR: ${{ runner.temp }}/allure-results + MERGED_DIR: ${{ runner.temp }}/allure-results-merged + REAL_BUILD_NUMBER: ${{ env.REAL_BUILD_NUMBER }} + NEW_APK_NAME: ${{ env.NEW_APK_NAME }} + INPUT_TAGS: ${{ inputs.TAGS }} + run: bash scripts/qa_android_ui_tests/reporting.sh merge-allure-results + + # Generate static Allure HTML from merged results. + - name: Generate Allure HTML report + if: always() + env: + MERGED_DIR: ${{ runner.temp }}/allure-results-merged + REPORT_DIR: ${{ runner.temp }}/allure-report + run: bash scripts/qa_android_ui_tests/reporting.sh generate-allure-report + + - name: Checkout GitHub Pages branch + if: always() + uses: actions/checkout@v4 + with: + ref: gh-pages + path: gh-pages + fetch-depth: 1 + persist-credentials: true + + # Publish report snapshots and apply retention cleanup. + - name: Publish Allure report to Pages branch + if: always() + env: + REPORT_DIR: ${{ runner.temp }}/allure-report + PAGES_DIR: gh-pages/docs/qa-ui-tests + KEEP_DAYS: "90" + INPUT_TAGS: ${{ inputs.TAGS }} + APK_VERSION: ${{ env.REAL_BUILD_NUMBER }} + APK_NAME: ${{ env.NEW_APK_NAME }} + run: bash scripts/qa_android_ui_tests/reporting.sh publish-allure-report + + # Clean temporary artifacts on the runner, even after failures. + - name: Cleanup (remove secrets + build outputs) + if: always() + env: + ALLURE_RESULTS_DIR: ${{ runner.temp }}/allure-results + ALLURE_RESULTS_MERGED_DIR: ${{ runner.temp }}/allure-results-merged + ALLURE_REPORT_DIR: ${{ runner.temp }}/allure-report + run: bash scripts/qa_android_ui_tests/reporting.sh cleanup-workspace diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 4aaf31c87ff..636ef18613c 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -12,7 +12,7 @@ ktx-dateTime = "0.6.1" ktx-immutableCollections = "0.3.8" ktx-serialization = "1.8.1" koin = "3.5.3" -datafaker = "2.4.2" +datafaker = "1.9.0" # Android Core / Architecture detekt = "1.23.8" diff --git a/scripts/qa_android_ui_tests/README.md b/scripts/qa_android_ui_tests/README.md new file mode 100644 index 00000000000..85a6773eab3 --- /dev/null +++ b/scripts/qa_android_ui_tests/README.md @@ -0,0 +1,29 @@ +# QA Android UI Tests Scripts + +These scripts back the workflow: + +- `.github/workflows/qa-android-ui-tests.yml` + +The workflow now calls a small set of phase-oriented scripts instead of many tiny one-off files. + +## Flavor Resolution Source + +Flavor resolution is runner-driven, not hardcoded in the repo. + +- Source of truth: `/etc/android-qa/flavors.json` (on the self-hosted runner) +- Executed via: `bash scripts/qa_android_ui_tests/execution_setup.sh resolve-flavor` +- Exports for later workflow steps: `S3_FOLDER`, `APP_ID`, `PACKAGES_TO_UNINSTALL` + +## Primary Scripts + +- `validation.sh`: input validation, TAG selector parsing, and resolved value logging. +- `execution_setup.sh`: runner prep, flavor/APK resolution, device prep, secrets fetch, and test artifact setup. +- `run_ui_tests.sh`: instrumentation execution/sharding across connected devices. +- `reporting.sh`: Allure pull/merge/generate/publish plus cleanup subcommands. + +## Python Helpers + +- `resolve_flavor.py`: parse `flavors.json` and export flavor-derived env vars. +- `select_apks.py`: resolve NEW/OLD APK keys based on input/build selection rules. +- `fetch_secrets_json.py`: build runtime `secrets.json` from 1Password vault items. +- `merge_allure_results.py`: merge per-device Allure outputs and attach metadata. diff --git a/scripts/qa_android_ui_tests/execution_setup.sh b/scripts/qa_android_ui_tests/execution_setup.sh new file mode 100755 index 00000000000..137cc23379c --- /dev/null +++ b/scripts/qa_android_ui_tests/execution_setup.sh @@ -0,0 +1,347 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Set up runner, device, and app prerequisites for qa-android-ui-tests workflow. + +usage() { + echo "Usage: $0 {ensure-required-tools|resolve-flavor|download-apks|detect-target-devices|install-apks-on-devices|fetch-runtime-secrets|build-test-apk|resolve-test-apk-path|resolve-test-services-apks}" >&2 + exit 2 +} + +ensure_required_tools() { + 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 + return + 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..." + local aws_cli_version="2.34.1" + local aws_cli_url="https://awscli.amazonaws.com/awscli-exe-linux-x86_64-${aws_cli_version}.zip" + # Update this checksum when bumping aws_cli_version. + local aws_cli_sha256="9307175fafe63cba37299f19bb82101662cea7cfa3d41797c460dc9ed560322d" + local aws_root="${RUNNER_TEMP}/awscli" + local zip_path="${RUNNER_TEMP}/awscliv2.zip" + + rm -rf "${aws_root}" "${zip_path}" "${RUNNER_TEMP}/aws" + mkdir -p "${aws_root}" + + curl -fsSL -o "${zip_path}" "${aws_cli_url}" + + local actual_sha256 + if command -v sha256sum >/dev/null 2>&1; then + actual_sha256="$(sha256sum "${zip_path}" | awk '{print $1}')" + elif command -v shasum >/dev/null 2>&1; then + actual_sha256="$(shasum -a 256 "${zip_path}" | awk '{print $1}')" + else + echo "ERROR: no SHA256 tool found (need sha256sum or shasum)" >&2 + rm -f "${zip_path}" + exit 1 + fi + + if [[ "${actual_sha256}" != "${aws_cli_sha256}" ]]; then + echo "ERROR: AWS CLI checksum verification failed" >&2 + rm -f "${zip_path}" + exit 1 + fi + + 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 +} + +resolve_flavor() { + python3 scripts/qa_android_ui_tests/resolve_flavor.py + echo "Resolved flavor from runner config: '${FLAVOR_INPUT:-}'" +} + +download_apks() { + : "${S3_BUCKET:?ERROR: Missing secret AWS_S3_BUCKET}" + : "${S3_FOLDER:?ERROR: S3_FOLDER missing}" + : "${RUNNER_TEMP:?RUNNER_TEMP not set}" + : "${GITHUB_ENV:?GITHUB_ENV not set}" + : "${GITHUB_OUTPUT:?GITHUB_OUTPUT not set}" + + 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" + + local apk_env_file="${RUNNER_TEMP}/apk_env.txt" + python3 scripts/qa_android_ui_tests/select_apks.py > "${apk_env_file}" + + local new_s3_key="" + local old_s3_key="" + while IFS= read -r line || [[ -n "${line}" ]]; do + [[ -z "${line}" ]] && continue + if [[ "${line}" != *=* ]]; then + echo "ERROR: Invalid output line from select_apks.py: ${line}" + exit 1 + fi + + local key="${line%%=*}" + local value="${line#*=}" + case "${key}" in + NEW_S3_KEY|OLD_S3_KEY|NEW_APK_NAME|OLD_APK_NAME|REAL_BUILD_NUMBER|OLD_BUILD_NUMBER) + printf '%s=%s\n' "${key}" "${value}" >> "$GITHUB_ENV" + printf '%s=%s\n' "${key}" "${value}" >> "$GITHUB_OUTPUT" + ;; + *) + echo "ERROR: Unexpected key from select_apks.py: ${key}" + exit 1 + ;; + esac + + case "${key}" in + NEW_S3_KEY) + new_s3_key="${value}" + ;; + OLD_S3_KEY) + old_s3_key="${value}" + ;; + esac + done < "${apk_env_file}" + + if [[ -z "${new_s3_key}" ]]; then + echo "ERROR: Missing NEW_S3_KEY from select_apks.py output" + exit 1 + fi + + local 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 + if [[ -z "${old_s3_key}" ]]; then + echo "ERROR: Missing OLD_S3_KEY for upgrade flow" + exit 1 + fi + local 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 +} + +detect_target_devices() { + : "${GITHUB_ENV:?GITHUB_ENV not set}" + + local device_lines + 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 + + local target="${TARGET_DEVICE_ID:-}" + local device_list + 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" + elif [[ -n "${RESOLVED_TESTCASE_ID:-}" ]]; then + device_list="$(printf '%s\n' "$device_lines" | head -n 1)" + echo "Single-testcase mode (${RESOLVED_TESTCASE_ID}): selected device ${device_list}" + else + device_list="$(printf '%s\n' "$device_lines" | xargs)" + fi + + local device_count + 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_count} device(s)" +} + +install_apks_on_devices() { + : "${DEVICE_LIST:?DEVICE_LIST missing}" + : "${APP_ID:?APP_ID missing}" + : "${NEW_APK_PATH:?NEW_APK_PATH missing}" + : "${GITHUB_ENV:?GITHUB_ENV not set}" + + local new_apk_device_path="/data/local/tmp/Wire.new.apk" + local old_apk_device_path="/data/local/tmp/Wire.old.apk" + echo "NEW_APK_DEVICE_PATH=${new_apk_device_path}" >> "$GITHUB_ENV" + echo "OLD_APK_DEVICE_PATH=${old_apk_device_path}" >> "$GITHUB_ENV" + + local install_flags="-r" + if [[ "${ENFORCE_APP_INSTALL:-}" == "true" ]]; then + install_flags="-r -d" + fi + + local packages_to_uninstall="${PACKAGES_TO_UNINSTALL:-}" + read -ra PACKAGES <<< "${packages_to_uninstall}" + + read -ra DEVICES <<< "${DEVICE_LIST}" + for serial in "${DEVICES[@]}"; do + local adb_cmd="adb -s ${serial}" + ${adb_cmd} wait-for-device + + local installed + installed="$(${adb_cmd} shell pm list packages || true)" + for pkg in "${PACKAGES[@]}"; do + if [[ -n "${pkg}" ]] && echo "${installed}" | grep -qx "package:${pkg}"; then + ${adb_cmd} uninstall "${pkg}" || true + fi + done + + if [[ "${IS_UPGRADE:-}" == "true" ]]; then + : "${OLD_APK_PATH:?OLD_APK_PATH missing for upgrade}" + ${adb_cmd} shell rm -f "${new_apk_device_path}" "${old_apk_device_path}" || true + ${adb_cmd} push "${OLD_APK_PATH}" "${old_apk_device_path}" >/dev/null + ${adb_cmd} push "${NEW_APK_PATH}" "${new_apk_device_path}" >/dev/null + ${adb_cmd} install ${install_flags} "${OLD_APK_PATH}" + else + ${adb_cmd} install ${install_flags} "${NEW_APK_PATH}" + fi + + if ! ${adb_cmd} shell pm list packages | grep -qx "package:${APP_ID}"; then + echo "ERROR: '${APP_ID}' not installed on ${serial}." + exit 1 + fi + done +} + +fetch_runtime_secrets() { + if [[ -z "${OP_SERVICE_ACCOUNT_TOKEN:-}" ]]; then + echo "ERROR: Missing OP_SERVICE_ACCOUNT_TOKEN secret" + exit 1 + fi + + : "${RUNNER_TEMP:?RUNNER_TEMP not set}" + : "${GITHUB_ENV:?GITHUB_ENV not set}" + + echo "::add-mask::${OP_SERVICE_ACCOUNT_TOKEN}" + + chmod +x ./gradlew + + local secrets_json_path="${RUNNER_TEMP}/secrets.json" + export SECRETS_JSON_PATH="${secrets_json_path}" + echo "SECRETS_JSON_PATH=${secrets_json_path}" >> "$GITHUB_ENV" + + python3 scripts/qa_android_ui_tests/fetch_secrets_json.py + + test -s "${secrets_json_path}" + chmod 600 "${secrets_json_path}" + + rm -f "secrets.json" || true + ln -s "${secrets_json_path}" "secrets.json" + chmod 600 "secrets.json" || true + + mkdir -p .git/info + grep -qxF "secrets.json" .git/info/exclude 2>/dev/null || echo "secrets.json" >> .git/info/exclude +} + +build_test_apk() { + ./gradlew :tests:testsCore:assembleDebugAndroidTest --no-daemon --no-configuration-cache +} + +resolve_test_apk_path() { + : "${GITHUB_ENV:?GITHUB_ENV not set}" + + local test_apk_path + test_apk_path="$(ls -1 tests/testsCore/build/outputs/apk/androidTest/debug/*.apk | head -n 1 || true)" + if [[ -z "${test_apk_path}" || ! -f "${test_apk_path}" ]]; then + echo "ERROR: Could not find built androidTest APK under tests/testsCore/build/outputs/apk/androidTest/debug/" + exit 1 + fi + echo "TEST_APK_PATH=${test_apk_path}" >> "$GITHUB_ENV" +} + +# Resolve newest cached artifacts so Test Services/Orchestrator can be installed without rebuilding. +resolve_test_services_apks() { + : "${GITHUB_ENV:?GITHUB_ENV not set}" + + roots=() + [[ -n "${GRADLE_USER_HOME:-}" && -d "${GRADLE_USER_HOME}" ]] && roots+=("${GRADLE_USER_HOME}") + [[ -d "${HOME}/.gradle" ]] && roots+=("${HOME}/.gradle") + + if [[ ${#roots[@]} -eq 0 ]]; then + echo "ERROR: Could not find any Gradle cache directory (no GRADLE_USER_HOME and no ~/.gradle)." + exit 1 + fi + + find_newest() { + local pattern="$1" + shift + local newest="" + local candidates=() + + for r in "$@"; do + while IFS= read -r -d '' f; do + candidates+=("$f") + done < <(find "$r" -type f -name "$pattern" -print0 2>/dev/null || true) + done + + if [[ ${#candidates[@]} -gt 0 ]]; then + newest="$(ls -t "${candidates[@]}" 2>/dev/null | head -n 1 || true)" + fi + + echo "$newest" + } + + local test_services_apk + local orchestrator_apk + test_services_apk="$(find_newest "*test-services*.apk" "${roots[@]}")" + orchestrator_apk="$(find_newest "*orchestrator*.apk" "${roots[@]}")" + + if [[ -z "${test_services_apk}" || ! -f "${test_services_apk}" ]]; then + echo "ERROR: Could not locate AndroidX Test Services APK in Gradle cache." + echo "This APK is required for Allure TestStorage (content://androidx.test.services.storage...)." + exit 1 + fi + + echo "TEST_SERVICES_APK_PATH=${test_services_apk}" >> "$GITHUB_ENV" + if [[ -n "${orchestrator_apk}" && -f "${orchestrator_apk}" ]]; then + echo "ORCHESTRATOR_APK_PATH=${orchestrator_apk}" >> "$GITHUB_ENV" + fi +} + +case "${1:-}" in + ensure-required-tools) + ensure_required_tools + ;; + resolve-flavor) + resolve_flavor + ;; + download-apks) + download_apks + ;; + detect-target-devices) + detect_target_devices + ;; + install-apks-on-devices) + install_apks_on_devices + ;; + fetch-runtime-secrets) + fetch_runtime_secrets + ;; + build-test-apk) + build_test_apk + ;; + resolve-test-apk-path) + resolve_test_apk_path + ;; + resolve-test-services-apks) + resolve_test_services_apks + ;; + *) + usage + ;; +esac diff --git a/scripts/qa_android_ui_tests/fetch_secrets_json.py b/scripts/qa_android_ui_tests/fetch_secrets_json.py new file mode 100755 index 00000000000..3cbfc3bf766 --- /dev/null +++ b/scripts/qa_android_ui_tests/fetch_secrets_json.py @@ -0,0 +1,54 @@ +#!/usr/bin/env python3 +"""Fetch all 1Password items in a vault and write a runtime secrets.json file.""" + +import json +import os +import subprocess +import sys + +vault = os.environ.get("OP_VAULT", "Test Automation") +out_path = os.environ.get("SECRETS_JSON_PATH") or "secrets.json" + + +def run_op(cmd): + result = subprocess.run(cmd, capture_output=True, text=True) + if result.returncode != 0: + sys.stderr.write(result.stderr or result.stdout or "op command failed\n") + sys.exit(result.returncode) + return result.stdout + + +list_out = run_op(["op", "item", "list", "--vault", vault, "--format", "json"]) +try: + items = json.loads(list_out) +except Exception as exc: + sys.stderr.write(f"Failed to parse op item list output: {exc}\n") + sys.exit(1) + +if not isinstance(items, list): + sys.stderr.write("Unexpected op item list output format\n") + sys.exit(1) + +combined = {} +for item in items: + item_id = item.get("id") + if not item_id: + continue + out = run_op(["op", "item", "get", item_id, "--vault", vault, "--format", "json"]) + data = json.loads(out) + fields_list = data.get("fields") or [] + fields_map = {} + for idx, field in enumerate(fields_list): + label = field.get("label") + if not label: + continue + # Preserve duplicate labels by appending an index suffix. + key = label if label not in fields_map else f"{label}_{idx}" + fields_map[key] = {"type": field.get("type"), "value": field.get("value")} + data["fields"] = fields_map + title = data.get("title") or item.get("title") or item_id + combined[title] = data + +# Write output to the temporary runtime file path used by the test run. +with open(out_path, "w", encoding="utf-8") as handle: + json.dump(combined, handle, ensure_ascii=True, indent=2) diff --git a/scripts/qa_android_ui_tests/merge_allure_results.py b/scripts/qa_android_ui_tests/merge_allure_results.py new file mode 100755 index 00000000000..56c578936d0 --- /dev/null +++ b/scripts/qa_android_ui_tests/merge_allure_results.py @@ -0,0 +1,143 @@ +#!/usr/bin/env python3 +"""Merge per-device Allure results into one reportable dataset.""" + +import json +import os +import shutil +import subprocess +from datetime import datetime, timezone +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 + + +def add_parameter(data: dict, name: str, value: str) -> dict: + params = [p for p in data.get("parameters", []) if p.get("name") != name] + params.append({"name": name, "value": value}) + data["parameters"] = params + return data + + +for device_dir in device_dirs: + serial = device_dir.name + # Support both pull layouts: /allure-results/* and /*. + 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 + # Attach a stable per-device label for filtering and debugging in Allure. + data = add_label(data, "device", label) + data = add_label(data, "host", label) + data = add_parameter(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) + +env_lines = [] +if device_info: + devices = ", ".join(device_label(serial) for serial in sorted(device_info.keys())) + env_lines.append(f"devices={devices}") + +apk_version = os.environ.get("REAL_BUILD_NUMBER", "").strip() +apk_name = os.environ.get("NEW_APK_NAME", "").strip() +if apk_version: + env_lines.append(f"apk={apk_version}") +elif apk_name: + env_lines.append(f"apk={apk_name}") + +run_number = os.environ.get("GITHUB_RUN_NUMBER", "").strip() +if run_number: + env_lines.append(f"run={run_number}") + +run_date = datetime.now(timezone.utc).strftime("%Y-%m-%d") +env_lines.append(f"date={run_date}") + +tags_input = os.environ.get("INPUT_TAGS", "").strip() +if tags_input: + env_lines.append(f"input_tags={tags_input}") + +if env_lines: + # Write Environment tab metadata for Allure. + (merged_dir / "environment.properties").write_text( + "\n".join(env_lines) + "\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 "" +build_name = run_number +if run_number and apk_version: + build_name = f"{run_number} / {apk_version}" +report_name = "Android UI Tests" +if apk_version: + report_name = f"Android UI Tests ({apk_version})" + +executor = { + "name": "GitHub Actions", + "type": "github", + "url": run_url, + "buildName": build_name, + "buildUrl": run_url, + "reportName": report_name, +} +# Write Executor widget metadata for Allure. +(merged_dir / "executor.json").write_text( + json.dumps(executor, ensure_ascii=True), + encoding="utf-8", +) diff --git a/scripts/qa_android_ui_tests/reporting.sh b/scripts/qa_android_ui_tests/reporting.sh new file mode 100755 index 00000000000..d1d798561db --- /dev/null +++ b/scripts/qa_android_ui_tests/reporting.sh @@ -0,0 +1,219 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Reporting and publication utilities for qa-android-ui-tests workflow. + +usage() { + echo "Usage: $0 {remove-runtime-secrets|pull-allure-results|merge-allure-results|generate-allure-report|publish-allure-report|cleanup-workspace}" >&2 + exit 2 +} + +remove_runtime_secrets() { + rm -f secrets.json || true + if [[ -n "${SECRETS_JSON_PATH:-}" ]]; then + rm -f "${SECRETS_JSON_PATH}" || true + fi +} + +pull_allure_results() { + if [[ -z "${DEVICE_LIST:-}" ]]; then + echo "No devices detected (skipping allure pull)" + return + fi + + local out_dir="${OUT_DIR:?OUT_DIR not set}" + mkdir -p "${out_dir}" + + read -ra DEVICES <<< "${DEVICE_LIST}" + local idx=1 + for serial in "${DEVICES[@]}"; do + echo "Pulling allure-results from device ${idx}/${DEVICE_COUNT}..." + mkdir -p "${out_dir}/${serial}" + adb -s "${serial}" pull "/sdcard/googletest/test_outputfiles/allure-results" "${out_dir}/${serial}" >/dev/null 2>&1 || true + idx=$((idx + 1)) + done +} + +merge_allure_results() { + python3 scripts/qa_android_ui_tests/merge_allure_results.py +} + +generate_allure_report() { + : "${MERGED_DIR:?MERGED_DIR not set}" + : "${REPORT_DIR:?REPORT_DIR not set}" + + if [[ ! -d "${MERGED_DIR}" ]]; then + echo "No merged Allure results found" + mkdir -p "${REPORT_DIR}" + cat > "${REPORT_DIR}/index.html" <<'HTML' + + +Allure Report +

No Allure results found

+ +HTML + return + fi + + if ! ls "${MERGED_DIR}"/*-result.json >/dev/null 2>&1; then + echo "No Allure result files found" + mkdir -p "${REPORT_DIR}" + cat > "${REPORT_DIR}/index.html" <<'HTML' + + +Allure Report +

No Allure result files found

+ +HTML + return + fi + + local allure_version="2.29.0" + # Update this checksum when bumping allure_version. + local allure_sha256="a217155db9670ab36ce7b0569b3fb0530a657c81bd7ce5bc974f0bba2a4d84fb" + local 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" + + if command -v sha256sum >/dev/null 2>&1; then + if ! echo "${allure_sha256} ${allure_tgz}" | sha256sum -c - >/dev/null 2>&1; then + echo "ERROR: Allure checksum verification failed" >&2 + rm -f "${allure_tgz}" + return 1 + fi + else + local actual_sha256 + actual_sha256="$(shasum -a 256 "${allure_tgz}" | awk '{print $1}')" + if [[ "${actual_sha256}" != "${allure_sha256}" ]]; then + echo "ERROR: Allure checksum verification failed" >&2 + rm -f "${allure_tgz}" + return 1 + fi + fi + + tar -xzf "${allure_tgz}" -C "${RUNNER_TEMP}" + rm -f "${allure_tgz}" + "${RUNNER_TEMP}/allure-${allure_version}/bin/allure" \ + generate "${MERGED_DIR}" -o "${REPORT_DIR}" --clean +} + +publish_allure_report() { + : "${REPORT_DIR:?REPORT_DIR not set}" + : "${PAGES_DIR:?PAGES_DIR not set}" + : "${KEEP_DAYS:?KEEP_DAYS not set}" + : "${APK_VERSION:=}" + : "${APK_NAME:=}" + : "${GITHUB_RUN_NUMBER:?GITHUB_RUN_NUMBER not set}" + : "${GITHUB_REPOSITORY:?GITHUB_REPOSITORY not set}" + : "${GITHUB_STEP_SUMMARY:?GITHUB_STEP_SUMMARY not set}" + + if [[ ! -d "${REPORT_DIR}" ]]; then + echo "Allure report not found, skipping publish." + return + fi + + local run_date + run_date="$(date -u +%Y-%m-%d)" + local apk_label="${APK_NAME:-${APK_VERSION:-}}" + local safe_apk + safe_apk="$(printf '%s' "${apk_label}" | tr -c 'A-Za-z0-9._-' '_' )" + local run_folder="${run_date}_run-${GITHUB_RUN_NUMBER}" + if [[ -n "${safe_apk}" ]]; then + run_folder="${run_folder}_apk-${safe_apk}" + fi + + rm -rf "${PAGES_DIR}/${run_folder}" + mkdir -p "${PAGES_DIR}/${run_folder}" + cp -a "${REPORT_DIR}/." "${PAGES_DIR}/${run_folder}/" + + if [[ -n "${KEEP_DAYS}" ]]; then + local cutoff + cutoff="$(date -u -d "${KEEP_DAYS} days ago" +%s)" + for run_dir in "${PAGES_DIR}"/20??-??-??_run-*; do + [[ -d "${run_dir}" ]] || continue + local base + base="$(basename "${run_dir}")" + local folder_date="${base%%_*}" + local ts + ts="$(date -u -d "${folder_date}" +%s 2>/dev/null || true)" + if [[ "${ts}" =~ ^[0-9]+$ ]] && (( ts < cutoff )); then + rm -rf "${run_dir}" + fi + done + fi + + local index_file="${PAGES_DIR}/index.html" + { + echo 'QA Android UI Tests' + echo '

QA Android UI Tests

' + echo '' + } > "${index_file}" + + cd gh-pages + if [[ -n "$(git status --porcelain)" ]]; then + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add docs/qa-ui-tests + git commit -m "Update Allure report (run ${GITHUB_RUN_NUMBER})" + git push origin gh-pages + else + echo "No changes to publish." + fi + + local org="${GITHUB_REPOSITORY%%/*}" + local repo="${GITHUB_REPOSITORY##*/}" + local base_url="https://${org}.github.io/${repo}" + echo "Allure report (run ${GITHUB_RUN_NUMBER}): ${base_url}/qa-ui-tests/${run_folder}/" >> "$GITHUB_STEP_SUMMARY" +} + +cleanup_workspace() { + : "${ALLURE_RESULTS_DIR:?ALLURE_RESULTS_DIR not set}" + : "${ALLURE_RESULTS_MERGED_DIR:?ALLURE_RESULTS_MERGED_DIR not set}" + : "${ALLURE_REPORT_DIR:?ALLURE_REPORT_DIR not set}" + + rm -f "secrets.json" "${RUNNER_TEMP}/secrets.json" || true + rm -f "${RUNNER_TEMP}/Wire.apk" "${RUNNER_TEMP}/Wire.old.apk" || true + + rm -rf "${ALLURE_RESULTS_DIR}" || true + rm -rf "${ALLURE_RESULTS_MERGED_DIR}" || true + rm -rf "${ALLURE_REPORT_DIR}" || true + + rm -rf "${RUNNER_TEMP}/instrumentation-logs" || true + git clean -ffdx -e .gradle -e .kotlin +} + +case "${1:-}" in + remove-runtime-secrets) + remove_runtime_secrets + ;; + pull-allure-results) + pull_allure_results + ;; + merge-allure-results) + merge_allure_results + ;; + generate-allure-report) + generate_allure_report + ;; + publish-allure-report) + publish_allure_report + ;; + cleanup-workspace) + cleanup_workspace + ;; + *) + usage + ;; +esac diff --git a/scripts/qa_android_ui_tests/resolve_flavor.py b/scripts/qa_android_ui_tests/resolve_flavor.py new file mode 100755 index 00000000000..6ca5c037fbc --- /dev/null +++ b/scripts/qa_android_ui_tests/resolve_flavor.py @@ -0,0 +1,58 @@ +#!/usr/bin/env python3 +"""Resolve flavor config from flavors.json and export workflow env vars.""" + +import json +import os +import sys + +flavor = (os.environ.get("FLAVOR_INPUT") or "").strip() +cfg_path = os.environ.get("FLAVORS_CONFIG_PATH") or "/etc/android-qa/flavors.json" + +if not cfg_path: + print("ERROR: FLAVORS_CONFIG_PATH not set", file=sys.stderr) + sys.exit(1) + +if not os.path.isfile(cfg_path): + print(f"ERROR: Missing flavors config on runner: {cfg_path}", file=sys.stderr) + sys.exit(1) + +try: + with open(cfg_path, "r", encoding="utf-8") as handle: + cfg = json.load(handle) +except Exception as exc: + print(f"ERROR: Failed to read {cfg_path}: {exc}", file=sys.stderr) + sys.exit(1) + +flavors = cfg.get("flavors") or {} +packages = cfg.get("packagesToUninstall") + +if flavor not in flavors: + print(f"ERROR: Flavor '{flavor}' not found in {cfg_path}", file=sys.stderr) + sys.exit(1) + +entry = flavors.get(flavor) or {} +s3 = (entry.get("s3Folder") or "").strip() +app = (entry.get("appId") or "").strip() + +if not s3 or not app: + print(f"ERROR: Flavor '{flavor}' missing s3Folder/appId in {cfg_path}", file=sys.stderr) + sys.exit(1) + +if packages is None: + pkgs = [] +elif isinstance(packages, list) and all(isinstance(x, str) for x in packages): + pkgs = [x.strip() for x in packages if x.strip()] +else: + print(f"ERROR: 'packagesToUninstall' must be an array of strings in {cfg_path}", file=sys.stderr) + sys.exit(1) + +env_path = os.environ.get("GITHUB_ENV") +if not env_path: + print("ERROR: GITHUB_ENV not set", file=sys.stderr) + sys.exit(1) + +with open(env_path, "a", encoding="utf-8") as handle: + # Export variables used by downstream setup/install workflow steps. + handle.write(f"S3_FOLDER={s3}\n") + handle.write(f"APP_ID={app}\n") + handle.write("PACKAGES_TO_UNINSTALL=" + " ".join(pkgs) + "\n") diff --git a/scripts/qa_android_ui_tests/run_ui_tests.sh b/scripts/qa_android_ui_tests/run_ui_tests.sh new file mode 100755 index 00000000000..e9133327cec --- /dev/null +++ b/scripts/qa_android_ui_tests/run_ui_tests.sh @@ -0,0 +1,128 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Run adb instrumentation on selected devices and aggregate pass/fail status. +: "${DEVICE_LIST:?DEVICE_LIST missing}" +: "${DEVICE_COUNT:?DEVICE_COUNT missing}" +: "${APP_ID:?APP_ID missing}" +: "${TEST_APK_PATH:?TEST_APK_PATH missing}" +: "${RUNNER_TEMP:?RUNNER_TEMP not set}" +: "${TEST_SERVICES_APK_PATH:?TEST_SERVICES_APK_PATH missing}" + +# Use one shard per device, but force one shard in single-testcase mode. +NUM_SHARDS="${DEVICE_COUNT}" +if [[ -n "${RESOLVED_TESTCASE_ID:-}" ]]; then + NUM_SHARDS="1" +fi + +read -ra DEVICES <<< "${DEVICE_LIST}" +echo "Sharding: numShards=${NUM_SHARDS}, deviceCount=${DEVICE_COUNT}" + +LOG_DIR="${RUNNER_TEMP}/instrumentation-logs" +mkdir -p "${LOG_DIR}" + +pids=() +shard_index=0 + +# Start one background worker per device for parallel execution. +for SERIAL in "${DEVICES[@]}"; do + ( + set -euo pipefail + ADB="adb -s ${SERIAL}" + + ${ADB} wait-for-device + ${ADB} install -r -t "${TEST_APK_PATH}" >/dev/null + + pkgs="$(${ADB} shell pm list packages 2>/dev/null | tr -d '\r' || true)" + if ! grep -Fxq "package:androidx.test.services" <<< "${pkgs}"; then + echo "[${SERIAL}] Installing androidx.test.services APK (required for Allure TestStorage)..." + ${ADB} install -r -t "${TEST_SERVICES_APK_PATH}" >/dev/null + fi + + if [[ -n "${ORCHESTRATOR_APK_PATH:-}" ]]; then + pkgs2="$(${ADB} shell pm list packages 2>/dev/null | tr -d '\r' || true)" + if ! grep -Fxq "package:androidx.test.orchestrator" <<< "${pkgs2}"; then + echo "[${SERIAL}] Installing androidx.test.orchestrator APK (optional)..." + ${ADB} install -r -t "${ORCHESTRATOR_APK_PATH}" >/dev/null || true + fi + fi + + # Resolve the instrumentation runner by preferred custom runner, then by APP_ID target. + instr_list="$(${ADB} shell pm list instrumentation 2>/dev/null | tr -d '\r' || true)" + INSTRUMENTATION="$(printf '%s\n' "${instr_list}" | grep -m1 'TaggedTestRunner' | sed -E 's/^instrumentation:([^ ]+).*/\1/' || true)" + if [[ -z "${INSTRUMENTATION}" ]]; then + INSTRUMENTATION="$(printf '%s\n' "${instr_list}" | grep -m1 "target=${APP_ID}" | sed -E 's/^instrumentation:([^ ]+).*/\1/' || true)" + fi + if [[ -z "${INSTRUMENTATION}" ]]; then + echo "[${SERIAL}] ERROR: Could not resolve instrumentation. Installed instrumentations:" + printf '%s\n' "${instr_list}" | sed -u "s/^/[${SERIAL}] /" + exit 1 + fi + + THIS_SHARD_INDEX="${shard_index}" + if [[ "${NUM_SHARDS}" == "1" ]]; then + THIS_SHARD_INDEX="0" + fi + + echo "[${SERIAL}] shardIndex=${THIS_SHARD_INDEX}/${NUM_SHARDS}" + + ALLURE_DEVICE_DIR="/sdcard/googletest/test_outputfiles/allure-results" + ${ADB} shell "rm -rf '${ALLURE_DEVICE_DIR}' && mkdir -p '${ALLURE_DEVICE_DIR}'" >/dev/null 2>&1 || true + + args=() + args+=(-e numShards "${NUM_SHARDS}") + args+=(-e shardIndex "${THIS_SHARD_INDEX}") + + if [[ -n "${RESOLVED_TESTCASE_ID:-}" ]]; then + args+=(-e testCaseId "${RESOLVED_TESTCASE_ID}") + fi + if [[ -n "${RESOLVED_CATEGORY:-}" ]]; then + args+=(-e category "${RESOLVED_CATEGORY}") + fi + + args+=(-e filter "com.wire.android.tests.support.suite.TaggedFilter") + + if [[ "${IS_UPGRADE:-}" == "true" ]]; then + args+=(-e newApkPath "${NEW_APK_DEVICE_PATH}") + args+=(-e oldApkPath "${OLD_APK_DEVICE_PATH}") + fi + + LOG_FILE="${LOG_DIR}/instrument-${SERIAL}.log" + + set +e + ${ADB} shell am instrument -w -r "${args[@]}" "${INSTRUMENTATION}" 2>&1 \ + | sed -u "s/^/[${SERIAL}] /" | tee "${LOG_FILE}" + rc=${PIPESTATUS[0]} + set -e + + if [[ "${rc}" -ne 0 ]]; then + echo "[${SERIAL}] instrumentation command failed (rc=${rc})" + exit 1 + fi + + # Treat known failure markers as failures even when instrumentation exits 0. + if grep -qE 'FAILURES!!!|INSTRUMENTATION_FAILED|INSTRUMENTATION_RESULT: shortMsg=Process crashed|INSTRUMENTATION_STATUS_CODE: -1|INSTRUMENTATION_CODE: -1' "${LOG_FILE}"; then + echo "[${SERIAL}] FAIL" + exit 1 + fi + + echo "[${SERIAL}] PASS" + exit 0 + ) & + pids+=("$!") + + shard_index=$((shard_index + 1)) +done + +failed=0 +# Wait for all workers and fail the step if any shard fails. +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 diff --git a/scripts/qa_android_ui_tests/select_apks.py b/scripts/qa_android_ui_tests/select_apks.py new file mode 100755 index 00000000000..8a82cb000f6 --- /dev/null +++ b/scripts/qa_android_ui_tests/select_apks.py @@ -0,0 +1,151 @@ +#!/usr/bin/env python3 +"""Resolve NEW/OLD APK S3 keys from workflow inputs and print env-style outputs.""" + +import json +import os +import re +import sys + +runner_temp = os.environ.get("RUNNER_TEMP") +if not runner_temp: + print("ERROR: RUNNER_TEMP not set", file=sys.stderr) + sys.exit(1) + +keys_path = os.path.join(runner_temp, "apk_keys.json") +try: + with open(keys_path, "r", encoding="utf-8") as handle: + data = json.load(handle) +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): + # Parse supported Wire naming schemes into sortable tuples. + 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): + # Extract a report-friendly version label (for example, 4.21.0-73937). + 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): + # Keep current behavior: return the first filename that contains the token. + if not substr: + return None + for key in apks: + if substr in key.split("/")[-1]: + return key + return None + + +def pick_by_filename(filename: str): + if not filename: + return None + for key in apks: + if key.split("/")[-1] == filename: + return key + return None + + +parsed = [] +for key in apks: + parsed_version = parse_version(key.split("/")[-1]) + if parsed_version is not None: + parsed.append((parsed_version, key)) +# Sort ascending; latest is the last entry. +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(value: str): + value = value.strip() + if value.startswith("s3://"): + parts = value.split("/", 3) + return parts[3] if len(parts) >= 4 else "" + return value.lstrip("/") + + +new_key = None +old_key = None + +# Selection modes: +# 1) direct APK filename/path +# 2) "latest" +# 3) build token (substring) +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"): + normalized_old = normalize_direct(old_input) + old_key = normalized_old if "/" in normalized_old else ( + pick_by_filename(normalized_old) or pick_by_substring(normalized_old) + ) + 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 key/value lines so caller can append them to GitHub env/output files. +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 ''}") diff --git a/scripts/qa_android_ui_tests/validation.sh b/scripts/qa_android_ui_tests/validation.sh new file mode 100755 index 00000000000..c4bc17ea5bc --- /dev/null +++ b/scripts/qa_android_ui_tests/validation.sh @@ -0,0 +1,79 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Validation and selector utilities used by qa-android-ui-tests workflow. + +usage() { + echo "Usage: $0 {validate-upgrade-inputs|resolve-selector-from-tags|print-resolved-values}" >&2 + exit 2 +} + +trim() { + echo "$1" | xargs +} + +validate_upgrade_inputs() { + if [[ "${IS_UPGRADE:-}" == "true" && -z "${OLD_BUILD_NUMBER:-}" ]]; then + echo "ERROR: oldBuildNumber is REQUIRED when isUpgrade=true" + exit 1 + fi +} + +resolve_selector_from_tags() { + : "${GITHUB_OUTPUT:?GITHUB_OUTPUT not set}" + + local testcase_id="" + local category="" + local tags_raw="${TAGS_RAW:-}" + + if [[ -n "$(trim "${tags_raw}")" ]]; then + local sel="" + IFS=',' read -ra parts <<< "${tags_raw}" + for p in "${parts[@]}"; do + local t + 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" +} + +print_resolved_values() { + echo "flavor=${FLAVOR_INPUT:-}" + echo "resolvedTestCaseId=${RESOLVED_TESTCASE_ID:-}" + echo "resolvedCategory=${RESOLVED_CATEGORY:-}" +} + +case "${1:-}" in + validate-upgrade-inputs) + validate_upgrade_inputs + ;; + resolve-selector-from-tags) + resolve_selector_from_tags + ;; + print-resolved-values) + print_resolved_values + ;; + *) + usage + ;; +esac diff --git a/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/criticalFlows/FileSharingBetweenTeams.kt b/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/criticalFlows/FileSharingBetweenTeams.kt index 14f7caa73f3..94031139e52 100644 --- a/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/criticalFlows/FileSharingBetweenTeams.kt +++ b/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/criticalFlows/FileSharingBetweenTeams.kt @@ -191,7 +191,6 @@ class FileSharingBetweenTeams : BaseUiTest() { "Device1", "user4Name" ) - waitFor(5) assertAudioMessageIsVisible() assertAudioTimeStartsAtZero() } @@ -200,7 +199,7 @@ class FileSharingBetweenTeams : BaseUiTest() { step("Play audio message and verify playback time progresses") { pages.conversationViewPage.apply { clickPlayButtonOnAudioMessage() - waitFor(18) + waitFor(10) clickPauseButtonOnAudioMessage() assertAudioTimeIsNotZeroAnymore() } @@ -220,7 +219,7 @@ class FileSharingBetweenTeams : BaseUiTest() { assertFileActionModalIsVisible() tapSaveButtonOnModal() assertFileSavedToastContain( - "The file AudioFile.mp3 was saved successfully to the Downloads folder" + "The file AudioFile( ?\\([0-9]+\\))?\\.mp3 was saved successfully to the Downloads folder" ) } } @@ -245,9 +244,10 @@ class FileSharingBetweenTeams : BaseUiTest() { step("Download image file and verify success toast") { pages.conversationViewPage.apply { + waitForPreviousFileSavedToastToDisappear() clickSaveButtonOnDownloadModal() assertFileSavedToastContain( - "The file ImageFile.jpg was saved successfully to the Downloads folder" + "The file ImageFile( ?\\([0-9]+\\))?\\.jpg was saved successfully to the Downloads folder" ) } } @@ -272,9 +272,10 @@ class FileSharingBetweenTeams : BaseUiTest() { step("Download text file and verify success toast") { pages.conversationViewPage.apply { + waitForPreviousFileSavedToastToDisappear() clickSaveButtonOnDownloadModal() assertFileSavedToastContain( - "The file TextFile.txt was saved successfully to the Downloads folder" + "The file TextFile( ?\\([0-9]+\\))?\\.txt was saved successfully to the Downloads folder" ) } } @@ -310,9 +311,10 @@ class FileSharingBetweenTeams : BaseUiTest() { step("Save video file and verify success toast") { pages.conversationViewPage.apply { + waitForPreviousFileSavedToastToDisappear() clickSaveButtonOnDownloadModal() assertFileSavedToastContain( - "The file VideoFile.mp4 was saved successfully to the Downloads folder" + "The file VideoFile( ?\\([0-9]+\\))?\\.mp4 was saved successfully to the Downloads folder" ) } } diff --git a/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/criticalFlows/PersonalAccountLifeCycle.kt b/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/criticalFlows/PersonalAccountLifeCycle.kt index 41e3c6ba921..25b14d6e463 100644 --- a/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/criticalFlows/PersonalAccountLifeCycle.kt +++ b/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/criticalFlows/PersonalAccountLifeCycle.kt @@ -42,6 +42,7 @@ import kotlin.getValue import com.wire.android.tests.core.BaseUiTest import com.wire.android.tests.support.tags.Category import com.wire.android.tests.support.tags.TestCaseId +import uiautomatorutils.KeyboardUtils.closeKeyboardIfOpened @RunWith(AndroidJUnit4::class) class PersonalAccountLifeCycle : BaseUiTest() { @@ -111,12 +112,13 @@ class PersonalAccountLifeCycle : BaseUiTest() { pages.registrationPage.apply { enterFirstName(personalUser?.name.orEmpty()) enterPassword(personalUser?.password.orEmpty()) + closeKeyboardIfOpened() enterConfirmPassword(personalUser?.password.orEmpty()) - clickShowPasswordEyeIcon() + closeKeyboardIfOpened() verifyConfirmPasswordIsCorrect(personalUser?.password.orEmpty()) clickHidePasswordEyeIcon() - + closeKeyboardIfOpened() checkIAgreeToShareAnonymousUsageData() clickContinueButton() @@ -124,6 +126,7 @@ class PersonalAccountLifeCycle : BaseUiTest() { clickContinueButton() } } + step("Fetch OTP to complete 2FA verification and complete registration") { val otp = runBlocking { InbucketClient.getVerificationCode( diff --git a/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/pages/ConversationListPage.kt b/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/pages/ConversationListPage.kt index aeee4a71303..c32e4cc8a79 100644 --- a/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/pages/ConversationListPage.kt +++ b/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/pages/ConversationListPage.kt @@ -18,6 +18,7 @@ package com.wire.android.tests.core.pages import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.uiautomator.By import androidx.test.uiautomator.UiDevice import org.junit.Assert import uiautomatorutils.UiSelectorParams @@ -43,7 +44,7 @@ data class ConversationListPage(private val device: UiDevice) { private val conversationNameSelector: (String) -> UiSelectorParams = { conversationName -> UiSelectorParams(text = conversationName) } - private val startNewConversation = UiSelectorParams(description = "Search for people or create a new conversation") + private val startNewConversation = UiSelectorParams(description = "New. Start a new conversation") private val backArrowButtonInsideSearchField = UiSelectorParams( className = "android.view.View", description = "Go back to add participants view" @@ -116,7 +117,14 @@ data class ConversationListPage(private val device: UiDevice) { } fun clickGroupConversation(conversationName: String): ConversationListPage { - val conversation = UiWaitUtils.waitElement(UiSelectorParams(text = conversationName)) + val conversation = device.wait( + androidx.test.uiautomator.Until.findObject(By.text(conversationName)), + 10_000 + ) + if (conversation == null) { + throw AssertionError("Group conversation '$conversationName' was not found.") + } + conversation.click() return this } diff --git a/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/pages/ConversationViewPage.kt b/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/pages/ConversationViewPage.kt index 222ca3b344b..ebbbc7ea35e 100644 --- a/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/pages/ConversationViewPage.kt +++ b/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/pages/ConversationViewPage.kt @@ -29,6 +29,7 @@ import org.junit.Assert import uiautomatorutils.UiSelectorParams import uiautomatorutils.UiWaitUtils import uiautomatorutils.UiWaitUtils.findElementOrNull +import java.util.regex.Pattern import kotlin.test.DefaultAsserter.assertTrue import kotlin.test.assertEquals @@ -234,12 +235,24 @@ data class ConversationViewPage(private val device: UiDevice) { return this } + fun waitForPreviousFileSavedToastToDisappear(timeoutMillis: Long = 7_000): ConversationViewPage { + val uiDevice = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) + uiDevice.wait( + Until.gone(By.textContains("was saved successfully to the Downloads folder")), + timeoutMillis + ) + return this + } + fun assertFileSavedToastContain(partialText: String): ConversationViewPage { - val toast = UiWaitUtils.waitElement(UiSelectorParams(textContains = partialText)) + val uiDevice = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) + + // Toasts are short-lived; wait for regex match by presence. + val toast = uiDevice.wait(Until.findObject(By.text(Pattern.compile(partialText))), 7_000) Assert.assertTrue( - "Toast message containing '$partialText' is not displayed.", - !toast.visibleBounds.isEmpty + "Toast message matching regex '$partialText' is not displayed.", + toast != null ) return this diff --git a/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/pages/SettingsPage.kt b/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/pages/SettingsPage.kt index 1a27de256f9..1f8e6799495 100644 --- a/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/pages/SettingsPage.kt +++ b/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/pages/SettingsPage.kt @@ -23,6 +23,7 @@ import androidx.test.espresso.matcher.ViewMatchers.assertThat import androidx.test.platform.app.InstrumentationRegistry import androidx.test.uiautomator.By import androidx.test.uiautomator.UiDevice +import androidx.test.uiautomator.UiScrollable import androidx.test.uiautomator.UiSelector import androidx.test.uiautomator.Until import backendUtils.team.TeamHelper @@ -191,10 +192,23 @@ data class SettingsPage(private val device: UiDevice) { return this } + fun scrollTextIntoView(text: String): SettingsPage { + val scrollable = UiScrollable(UiSelector().scrollable(true)) + scrollable.setAsVerticalList() + scrollable.setMaxSearchSwipes(20) + val found = scrollable.scrollIntoView(UiSelector().textContains(text)) + assertTrue("Text '$text' was not found in scrollable view", found) + return this + } + fun assertAnalyticsTrackingIdentifierIsDispayed(): SettingsPage { + scrollTextIntoView("Analytics Tracking Identifier") + val container = device.findObject( UiSelector().className("android.view.View").childSelector(analyticsTrackingLabel) ) + assertTrue("'Analytics Tracking Identifier' row is not visible", container.exists()) + val identifierView = container.getFromParent( UiSelector().className("android.widget.TextView").instance(1) ) diff --git a/tests/testsSupport/src/main/call/CallingServiceClient.kt b/tests/testsSupport/src/main/call/CallingServiceClient.kt index d070d7eed96..c8452a16004 100644 --- a/tests/testsSupport/src/main/call/CallingServiceClient.kt +++ b/tests/testsSupport/src/main/call/CallingServiceClient.kt @@ -41,7 +41,7 @@ import user.utils.ClientUser class CallingServiceClient { companion object { - const val API_ROOT = "https://qa-callingservice-wire.runs.onstackit.cloud" + const val API_ROOT = "https://callingservice.dev.wire.link" } private val callingService = CallingService(API_ROOT) diff --git a/tests/testsSupport/src/main/res/raw/test.m4a b/tests/testsSupport/src/main/res/raw/test.m4a index 3085d513149..ab94045950b 100644 Binary files a/tests/testsSupport/src/main/res/raw/test.m4a and b/tests/testsSupport/src/main/res/raw/test.m4a differ diff --git a/tests/testsSupport/src/main/uiautomatorutils/UiWaitUtils.kt b/tests/testsSupport/src/main/uiautomatorutils/UiWaitUtils.kt index f45a3e40433..f251e3f2a67 100644 --- a/tests/testsSupport/src/main/uiautomatorutils/UiWaitUtils.kt +++ b/tests/testsSupport/src/main/uiautomatorutils/UiWaitUtils.kt @@ -28,11 +28,13 @@ import androidx.test.uiautomator.UiObject2 import androidx.test.uiautomator.UiSelector import androidx.test.uiautomator.Until import java.io.IOException +import java.util.regex.Pattern private const val TIMEOUT_IN_MILLISECONDS = 10000L data class UiSelectorParams( val text: String? = null, val textContains: String? = null, + val textMatches: String? = null, val resourceId: String? = null, val className: String? = null, val description: String? = null, @@ -52,6 +54,7 @@ object UiWaitUtils { private fun buildSelector(params: UiSelectorParams): BySelector { var selector: BySelector? = when { params.text != null -> By.text(params.text) + params.textMatches != null -> By.text(Pattern.compile(params.textMatches)) params.textContains != null -> By.textContains(params.textContains) else -> null } @@ -133,6 +136,7 @@ object UiWaitUtils { private fun describe(params: UiSelectorParams) = listOfNotNull( params.text?.let { "text='$it'" }, params.textContains?.let { "textContains='$it'" }, + params.textMatches?.let { "textMatches='$it'" }, params.resourceId?.let { "resourceId='$it'" }, params.className?.let { "className='$it'" }, params.description?.let { "description='$it'" }