diff --git a/.github/workflows/qa-android-ui-tests.yml b/.github/workflows/qa-android-ui-tests.yml index 82812542e79..8b9deabb9be 100644 --- a/.github/workflows/qa-android-ui-tests.yml +++ b/.github/workflows/qa-android-ui-tests.yml @@ -69,6 +69,18 @@ on: default: "" type: string + rerunFailedEnabled: + description: "Automatically rerun only failed tests in this run." + required: true + default: true + type: boolean + + rerunFailedCount: + description: "How many failed-test rerun attempts (0-3). Default is 1." + required: true + default: "1" + type: string + permissions: contents: read @@ -94,6 +106,14 @@ jobs: OLD_BUILD_NUMBER: ${{ inputs.oldBuildNumber }} run: bash scripts/qa_android_ui_tests/validation.sh validate-upgrade-inputs + # Validate retry toggle/count before any runner work starts. + - name: Validate rerun inputs + shell: bash + env: + RERUN_FAILED_ENABLED: ${{ inputs.rerunFailedEnabled }} + RERUN_FAILED_COUNT: ${{ inputs.rerunFailedCount }} + run: bash scripts/qa_android_ui_tests/validation.sh validate-rerun-inputs + # Resolve TAGS into CI selectors and expose them as job outputs. - name: Resolve selector from TAGS id: resolve_selector @@ -186,6 +206,10 @@ jobs: RESOLVED_TESTCASE_ID: ${{ needs.validate-and-resolve-inputs.outputs.resolvedTestCaseId }} run: bash scripts/qa_android_ui_tests/execution_setup.sh detect-target-devices + # Clear stale device-side Allure files early so setup failures cannot publish old reports. + - name: Clear stale Allure results on device(s) + run: bash scripts/qa_android_ui_tests/execution_setup.sh clear-allure-results-on-devices + # Install app/test prerequisites on each selected device. - name: Install APK(s) on device(s) env: @@ -214,12 +238,15 @@ jobs: - 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) + # Run attempt 0, pull results immediately, then rerun only the still-failing tests. + - name: Run UI tests (auto-rerun failed tests) env: RESOLVED_TESTCASE_ID: ${{ needs.validate-and-resolve-inputs.outputs.resolvedTestCaseId }} RESOLVED_CATEGORY: ${{ needs.validate-and-resolve-inputs.outputs.resolvedCategory }} IS_UPGRADE: ${{ inputs.isUpgrade }} + RERUN_FAILED_ENABLED: ${{ inputs.rerunFailedEnabled }} + RERUN_FAILED_COUNT: ${{ inputs.rerunFailedCount }} + ALLURE_RESULTS_ROOT: ${{ runner.temp }}/allure-results run: bash scripts/qa_android_ui_tests/run_ui_tests.sh # Remove runtime secrets before report generation and publish steps. @@ -227,14 +254,14 @@ jobs: 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) + # Fallback pull: keep this as a safety net in case per-attempt pull was interrupted. + - name: Pull Allure results from device(s) (fallback) 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. + # Merge all attempts into one final dataset and stamp passed_on_rerun=true where needed. - name: Merge Allure results (add device label) if: always() env: diff --git a/scripts/qa_android_ui_tests/README.md b/scripts/qa_android_ui_tests/README.md index 85a6773eab3..153fc06a736 100644 --- a/scripts/qa_android_ui_tests/README.md +++ b/scripts/qa_android_ui_tests/README.md @@ -18,7 +18,7 @@ Flavor resolution is runner-driven, not hardcoded in the repo. - `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. +- `run_ui_tests.sh`: instrumentation execution/sharding plus failed-test auto-reruns (explicit per-device retry lists with even count balancing). - `reporting.sh`: Allure pull/merge/generate/publish plus cleanup subcommands. ## Python Helpers @@ -27,3 +27,4 @@ Flavor resolution is runner-driven, not hardcoded in the repo. - `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. +- `extract_failed_tests.py`: extract failed test IDs (`Class#method`) from one attempt's Allure result files. diff --git a/scripts/qa_android_ui_tests/__pycache__/extract_failed_tests.cpython-313.pyc b/scripts/qa_android_ui_tests/__pycache__/extract_failed_tests.cpython-313.pyc new file mode 100644 index 00000000000..73fcad955a7 Binary files /dev/null and b/scripts/qa_android_ui_tests/__pycache__/extract_failed_tests.cpython-313.pyc differ diff --git a/scripts/qa_android_ui_tests/__pycache__/merge_allure_results.cpython-313.pyc b/scripts/qa_android_ui_tests/__pycache__/merge_allure_results.cpython-313.pyc new file mode 100644 index 00000000000..3f2b2a57461 Binary files /dev/null and b/scripts/qa_android_ui_tests/__pycache__/merge_allure_results.cpython-313.pyc differ diff --git a/scripts/qa_android_ui_tests/execution_setup.sh b/scripts/qa_android_ui_tests/execution_setup.sh index 137cc23379c..3f824e887aa 100755 --- a/scripts/qa_android_ui_tests/execution_setup.sh +++ b/scripts/qa_android_ui_tests/execution_setup.sh @@ -4,7 +4,7 @@ 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 + echo "Usage: $0 {ensure-required-tools|resolve-flavor|download-apks|detect-target-devices|clear-allure-results-on-devices|install-apks-on-devices|fetch-runtime-secrets|build-test-apk|resolve-test-apk-path|resolve-test-services-apks}" >&2 exit 2 } @@ -169,6 +169,17 @@ detect_target_devices() { echo "Using ${device_count} device(s)" } +clear_allure_results_on_devices() { + : "${DEVICE_LIST:?DEVICE_LIST missing}" + + read -ra DEVICES <<< "${DEVICE_LIST}" + for serial in "${DEVICES[@]}"; do + adb -s "${serial}" wait-for-device + # Clear stale device-side Allure files before the workflow reaches any later setup step that might fail. + adb -s "${serial}" shell "rm -rf '/sdcard/googletest/test_outputfiles/allure-results' && mkdir -p '/sdcard/googletest/test_outputfiles/allure-results'" >/dev/null 2>&1 || true + done +} + install_apks_on_devices() { : "${DEVICE_LIST:?DEVICE_LIST missing}" : "${APP_ID:?APP_ID missing}" @@ -301,9 +312,48 @@ resolve_test_services_apks() { test_services_apk="$(find_newest "*test-services*.apk" "${roots[@]}")" orchestrator_apk="$(find_newest "*orchestrator*.apk" "${roots[@]}")" + read_version_from_catalog() { + local key="$1" + awk -F'"' -v wanted="${key}" '$1 ~ ("^" wanted " *= *$") { print $2; exit }' gradle/libs.versions.toml + } + + download_from_google_maven() { + local group_path="$1" + local artifact="$2" + local version="$3" + local out_dir="${RUNNER_TEMP:-/tmp}/androidx-test-apks" + local out_path="${out_dir}/${artifact}-${version}.apk" + + mkdir -p "${out_dir}" + curl -fsSL \ + -o "${out_path}" \ + "https://dl.google.com/dl/android/maven2/${group_path}/${artifact}/${version}/${artifact}-${version}.apk" + echo "${out_path}" + } + + # On a clean/self-hosted runner, these APK artifacts may not exist in cache yet. + # If cache lookup misses, download them directly from the official Google Maven repository. if [[ -z "${test_services_apk}" || ! -f "${test_services_apk}" ]]; then - echo "ERROR: Could not locate AndroidX Test Services APK in Gradle cache." + local test_services_version + test_services_version="$(read_version_from_catalog "androidx-test-services")" + if [[ -n "${test_services_version}" ]]; then + test_services_apk="$(download_from_google_maven "androidx/test/services" "test-services" "${test_services_version}")" + fi + fi + + if [[ -z "${orchestrator_apk}" || ! -f "${orchestrator_apk}" ]]; then + local orchestrator_version + orchestrator_version="$(read_version_from_catalog "androidx-test-orchestrator")" + if [[ -n "${orchestrator_version}" ]]; then + orchestrator_apk="$(download_from_google_maven "androidx/test" "orchestrator" "${orchestrator_version}")" + fi + fi + + if [[ -z "${test_services_apk}" || ! -f "${test_services_apk}" ]]; then + echo "ERROR: Could not locate or download AndroidX Test Services APK." echo "This APK is required for Allure TestStorage (content://androidx.test.services.storage...)." + printf 'Searched cache roots:\n' >&2 + printf ' - %s\n' "${roots[@]}" >&2 exit 1 fi @@ -326,6 +376,9 @@ case "${1:-}" in detect-target-devices) detect_target_devices ;; + clear-allure-results-on-devices) + clear_allure_results_on_devices + ;; install-apks-on-devices) install_apks_on_devices ;; diff --git a/scripts/qa_android_ui_tests/extract_failed_tests.py b/scripts/qa_android_ui_tests/extract_failed_tests.py new file mode 100755 index 00000000000..1f56757bcfa --- /dev/null +++ b/scripts/qa_android_ui_tests/extract_failed_tests.py @@ -0,0 +1,95 @@ +#!/usr/bin/env python3 +"""Extract failed test IDs (Class#method) from one attempt of Allure results.""" + +from __future__ import annotations + +import json +import os +import sys +from pathlib import Path + +attempt_dir = Path(os.environ["ATTEMPT_RESULTS_DIR"]) +failed_output = Path(os.environ["FAILED_TESTS_FILE"]) + +if not attempt_dir.is_dir(): + print(f"ERROR: ATTEMPT_RESULTS_DIR does not exist: {attempt_dir}", file=sys.stderr) + sys.exit(1) + +FAILED_STATUSES = {"failed", "broken", "unknown"} + + +def test_id_from_labels(data: dict) -> str: + labels = data.get("labels", []) + if not isinstance(labels, list): + return "" + class_name = "" + method_name = "" + for label in labels: + if not isinstance(label, dict): + continue + name = label.get("name") + value = label.get("value") + if not isinstance(value, str): + continue + if name == "testClass" and not class_name: + class_name = value.strip() + elif name == "testMethod" and not method_name: + method_name = value.strip() + if class_name and method_name: + return f"{class_name}#{method_name}" + return "" + + +def test_id_from_full_name(data: dict) -> str: + full_name = data.get("fullName") + if not isinstance(full_name, str): + return "" + full_name = full_name.strip() + if not full_name: + return "" + if "#" in full_name: + return full_name + if "." not in full_name: + return "" + class_name, method_name = full_name.rsplit(".", 1) + class_name = class_name.strip() + method_name = method_name.strip() + if class_name and method_name: + return f"{class_name}#{method_name}" + return "" + + +def resolve_test_id(data: dict) -> str: + return test_id_from_labels(data) or test_id_from_full_name(data) + + +def result_dirs(base: Path) -> list[Path]: + out = [] + for device_dir in sorted(p for p in base.iterdir() if p.is_dir()): + candidate = device_dir / "allure-results" + out.append(candidate if candidate.is_dir() else device_dir) + return out + + +failed = set() +executed = set() + +for src_dir in result_dirs(attempt_dir): + for result_file in sorted(src_dir.glob("*-result.json")): + try: + data = json.loads(result_file.read_text(encoding="utf-8")) + except Exception: + continue + test_id = resolve_test_id(data) + if not test_id: + continue + executed.add(test_id) + status = data.get("status") + if isinstance(status, str) and status in FAILED_STATUSES: + failed.add(test_id) + +failed_output.parent.mkdir(parents=True, exist_ok=True) +failed_output.write_text("\n".join(sorted(failed)) + ("\n" if failed else ""), encoding="utf-8") + +print(f"executed={len(executed)}") +print(f"failed={len(failed)}") diff --git a/scripts/qa_android_ui_tests/merge_allure_results.py b/scripts/qa_android_ui_tests/merge_allure_results.py index 56c578936d0..5020af5bba9 100755 --- a/scripts/qa_android_ui_tests/merge_allure_results.py +++ b/scripts/qa_android_ui_tests/merge_allure_results.py @@ -1,6 +1,9 @@ #!/usr/bin/env python3 -"""Merge per-device Allure results into one reportable dataset.""" +"""Merge Allure results across devices and retry attempts.""" +from __future__ import annotations + +import hashlib import json import os import shutil @@ -12,6 +15,9 @@ merged_dir = Path(os.environ["MERGED_DIR"]) merged_dir.mkdir(parents=True, exist_ok=True) +FAILED_STATUSES = {"failed", "broken", "unknown"} +PASSED_ON_RERUN_LABEL = "passed_on_rerun" + def get_prop(serial: str, prop: str) -> str: try: @@ -27,46 +33,135 @@ def get_prop(serial: str, prop: str) -> str: 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 list_attempt_device_dirs(base_dir: Path) -> list[tuple[int, Path]]: + def contains_result_files(device_dir: Path) -> bool: + src_candidate = device_dir / "allure-results" + src_dir = src_candidate if src_candidate.is_dir() else device_dir + return src_dir.is_dir() and any(src_dir.glob("*-result.json")) + + # Discover retry-aware layout first: OUT_DIR/attempt-N//... + attempt_dirs = [] + for candidate in sorted(base_dir.iterdir()): + if not candidate.is_dir(): + continue + if not candidate.name.startswith("attempt-"): + continue + suffix = candidate.name[len("attempt-") :] + if not suffix.isdigit(): + continue + attempt_dirs.append((int(suffix), candidate)) + pairs: list[tuple[int, Path]] = [] + if attempt_dirs: + max_attempt = 0 + for attempt, attempt_dir in sorted(attempt_dirs, key=lambda item: item[0]): + max_attempt = max(max_attempt, attempt) + for device_dir in sorted(p for p in attempt_dir.iterdir() if p.is_dir()): + pairs.append((attempt, device_dir)) -def device_label(serial: str) -> str: - meta = device_info.get(serial, {}) + # reporting.sh fallback pull stores results under OUT_DIR//... + # Treat those as the latest synthetic attempt so fallback data is never ignored. + synthetic_attempt = max_attempt + 1 + for device_dir in sorted(p for p in base_dir.iterdir() if p.is_dir() and not p.name.startswith("attempt-")): + if contains_result_files(device_dir): + pairs.append((synthetic_attempt, device_dir)) + return pairs + + for device_dir in sorted(p for p in base_dir.iterdir() if p.is_dir()): + if contains_result_files(device_dir): + pairs.append((0, device_dir)) + return pairs + + +def resolve_src_dir(device_dir: Path) -> Path: + candidate = device_dir / "allure-results" + return candidate if candidate.is_dir() else device_dir + + +def device_label(serial: str, cache: dict[str, dict[str, str]]) -> str: + meta = cache.get(serial) or {} 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 = [label for label in data.get("labels", []) if label.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 = [param for param in data.get("parameters", []) if param.get("name") != name] params.append({"name": name, "value": value}) data["parameters"] = params return data -for device_dir in device_dirs: +def result_identity(data: dict, fallback: str) -> str: + full_name = data.get("fullName") + if isinstance(full_name, str) and full_name.strip(): + return full_name.strip() + + labels = data.get("labels", []) + if isinstance(labels, list): + class_name = "" + method_name = "" + for label in labels: + if not isinstance(label, dict): + continue + name = label.get("name") + value = label.get("value") + if not isinstance(value, str): + continue + if name == "testClass" and not class_name: + class_name = value.strip() + elif name == "testMethod" and not method_name: + method_name = value.strip() + if class_name and method_name: + return f"{class_name}.{method_name}" + return fallback + + +def safe_write_result(filename: str, payload: str) -> None: + target = merged_dir / filename + if not target.exists(): + target.write_text(payload, encoding="utf-8") + return + + # Two different source files can share the same filename across attempts/devices. + existing = target.read_text(encoding="utf-8") + if existing == payload: + return + + digest = hashlib.sha1(payload.encode("utf-8")).hexdigest()[:10] + alt = merged_dir / f"{digest}-{filename}" + alt.write_text(payload, encoding="utf-8") + + +attempt_device_dirs = list_attempt_device_dirs(out_dir) +device_info: dict[str, dict[str, str]] = {} +# Resolve device metadata once so the same label is reused across all attempts. +for _, device_dir in attempt_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 serial in device_info: + continue + 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} + +first_status: dict[str, str] = {} +latest_by_test: dict[str, dict] = {} + +# Walk every device/attempt result and keep the latest outcome for each logical test. +for attempt, device_dir in attempt_device_dirs: + serial = device_dir.name + src_dir = resolve_src_dir(device_dir) if not src_dir.is_dir(): continue - label = device_label(serial) + label = device_label(serial, device_info) for item in src_dir.iterdir(): if item.is_dir(): continue @@ -77,20 +172,58 @@ def add_parameter(data: dict, name: str, value: str) -> dict: 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", - ) + data = add_parameter(data, "attempt", str(attempt)) + + # Use one stable identity across attempts so the final report keeps the latest outcome per test. + identity = result_identity(data, fallback=item.stem) + status = str(data.get("status") or "").strip().lower() + if identity not in first_status: + first_status[identity] = status + + prev = latest_by_test.get(identity) + if prev is None or attempt >= prev["attempt"]: + latest_by_test[identity] = { + "attempt": attempt, + "status": status, + "name": item.name, + "data": data, + } else: shutil.copy2(item, merged_dir / item.name) +# Write the final reportable result set after all attempts have been compared. +for identity, info in latest_by_test.items(): + data = info["data"] + initial_status = first_status.get(identity, "") + final_status = str(info.get("status", "")).lower() + # Merge logic is the single owner of passed_on_rerun because it can see first and final status together. + if initial_status in FAILED_STATUSES and final_status == "passed": + data = add_label(data, PASSED_ON_RERUN_LABEL, "true") + + payload = json.dumps(data, ensure_ascii=True) + safe_write_result(info["name"], payload) + +failed_first_attempt = sum(1 for status in first_status.values() if status in FAILED_STATUSES) +failed_after_retries = sum( + 1 + for info in latest_by_test.values() + if str(info.get("status", "")).lower() in FAILED_STATUSES +) +passed_on_rerun_count = 0 +for identity, info in latest_by_test.items(): + initial_status = first_status.get(identity, "") + final_status = str(info.get("status", "")).lower() + if initial_status in FAILED_STATUSES and final_status == "passed": + passed_on_rerun_count += 1 + +# Write Environment tab metadata for the merged Allure report. env_lines = [] if device_info: - devices = ", ".join(device_label(serial) for serial in sorted(device_info.keys())) + devices = ", ".join(device_label(serial, device_info) for serial in sorted(device_info.keys())) env_lines.append(f"devices={devices}") apk_version = os.environ.get("REAL_BUILD_NUMBER", "").strip() @@ -111,10 +244,14 @@ def add_parameter(data: dict, name: str, value: str) -> dict: if tags_input: env_lines.append(f"input_tags={tags_input}") +env_lines.append(f"failed_first_attempt={failed_first_attempt}") +env_lines.append(f"passed_on_rerun={passed_on_rerun_count}") +env_lines.append(f"failed_after_retries={failed_after_retries}") + if env_lines: - # Write Environment tab metadata for Allure. (merged_dir / "environment.properties").write_text( - "\n".join(env_lines) + "\n", encoding="utf-8" + "\n".join(env_lines) + "\n", + encoding="utf-8", ) run_id = os.environ.get("GITHUB_RUN_ID", "") @@ -128,6 +265,7 @@ def add_parameter(data: dict, name: str, value: str) -> dict: if apk_version: report_name = f"Android UI Tests ({apk_version})" +# Write Executor widget metadata so Allure links back to this GitHub Actions run. executor = { "name": "GitHub Actions", "type": "github", @@ -136,7 +274,6 @@ def add_parameter(data: dict, name: str, value: str) -> dict: "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 index d1d798561db..da0529f56fc 100755 --- a/scripts/qa_android_ui_tests/reporting.sh +++ b/scripts/qa_android_ui_tests/reporting.sh @@ -16,14 +16,23 @@ remove_runtime_secrets() { } pull_allure_results() { + local out_dir="${OUT_DIR:?OUT_DIR not set}" + mkdir -p "${out_dir}" + + # Retry-aware runs already persist per-attempt results during execution. + if compgen -G "${out_dir}/attempt-*" >/dev/null; then + if find "${out_dir}"/attempt-* -type f -name '*-result.json' -print -quit | grep -q .; then + echo "Per-attempt Allure results already present under ${out_dir}; skipping fallback pull." + return + fi + echo "Attempt folders exist but no result files found yet; running fallback pull." + fi + 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 diff --git a/scripts/qa_android_ui_tests/run_ui_tests.sh b/scripts/qa_android_ui_tests/run_ui_tests.sh index e9133327cec..c3f984c1778 100755 --- a/scripts/qa_android_ui_tests/run_ui_tests.sh +++ b/scripts/qa_android_ui_tests/run_ui_tests.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash set -euo pipefail -# Run adb instrumentation on selected devices and aggregate pass/fail status. +# Run attempt 0 and retry only failed tests based on workflow rerun inputs. : "${DEVICE_LIST:?DEVICE_LIST missing}" : "${DEVICE_COUNT:?DEVICE_COUNT missing}" : "${APP_ID:?APP_ID missing}" @@ -9,120 +9,520 @@ set -euo pipefail : "${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" +is_true() { + [[ "${1:-}" == "true" ]] +} + +RERUN_FAILED_ENABLED="${RERUN_FAILED_ENABLED:-true}" +RERUN_FAILED_COUNT="${RERUN_FAILED_COUNT:-1}" +ALLURE_RESULTS_ROOT="${ALLURE_RESULTS_ROOT:-${RUNNER_TEMP}/allure-results}" +ALLURE_PULL_MAX_ATTEMPTS="${ALLURE_PULL_MAX_ATTEMPTS:-3}" +ALLURE_PULL_BASE_DELAY_SEC="${ALLURE_PULL_BASE_DELAY_SEC:-5}" +RERUN_INLINE_PART_MAX_CHARS="${RERUN_INLINE_PART_MAX_CHARS:-7000}" + +if [[ ! "${RERUN_FAILED_ENABLED}" =~ ^(true|false)$ ]]; then + echo "ERROR: RERUN_FAILED_ENABLED must be true or false." + exit 1 fi -read -ra DEVICES <<< "${DEVICE_LIST}" -echo "Sharding: numShards=${NUM_SHARDS}, deviceCount=${DEVICE_COUNT}" +if [[ ! "${RERUN_FAILED_COUNT}" =~ ^[0-9]+$ ]]; then + echo "ERROR: RERUN_FAILED_COUNT must be a whole number >= 0." + exit 1 +fi + +if [[ ! "${ALLURE_PULL_MAX_ATTEMPTS}" =~ ^[0-9]+$ || "${ALLURE_PULL_MAX_ATTEMPTS}" == "0" ]]; then + echo "ERROR: ALLURE_PULL_MAX_ATTEMPTS must be a whole number >= 1." + exit 1 +fi + +if [[ ! "${ALLURE_PULL_BASE_DELAY_SEC}" =~ ^[0-9]+$ ]]; then + echo "ERROR: ALLURE_PULL_BASE_DELAY_SEC must be a whole number >= 0." + exit 1 +fi + +if [[ ! "${RERUN_INLINE_PART_MAX_CHARS}" =~ ^[0-9]+$ || $((10#${RERUN_INLINE_PART_MAX_CHARS})) -lt 256 ]]; then + echo "ERROR: RERUN_INLINE_PART_MAX_CHARS must be a whole number >= 256." + exit 1 +fi + +MAX_RERUNS=0 +if is_true "${RERUN_FAILED_ENABLED}"; then + MAX_RERUNS="$((10#${RERUN_FAILED_COUNT}))" +fi +MAX_PULL_ATTEMPTS="$((10#${ALLURE_PULL_MAX_ATTEMPTS}))" +PULL_BASE_DELAY_SEC="$((10#${ALLURE_PULL_BASE_DELAY_SEC}))" +INLINE_PART_MAX_CHARS="$((10#${RERUN_INLINE_PART_MAX_CHARS}))" LOG_DIR="${RUNNER_TEMP}/instrumentation-logs" -mkdir -p "${LOG_DIR}" +STATE_DIR="${RUNNER_TEMP}/retry-state" +mkdir -p "${LOG_DIR}" "${STATE_DIR}" "${ALLURE_RESULTS_ROOT}" -pids=() -shard_index=0 +read -ra DEVICES <<< "${DEVICE_LIST}" +RETRY_DEVICES=("${DEVICES[@]}") -# Start one background worker per device for parallel execution. -for SERIAL in "${DEVICES[@]}"; do - ( - set -euo pipefail - ADB="adb -s ${SERIAL}" +BASE_NUM_SHARDS="${DEVICE_COUNT}" +if [[ -n "${RESOLVED_TESTCASE_ID:-}" ]]; then + BASE_NUM_SHARDS="1" +fi - ${ADB} wait-for-device - ${ADB} install -r -t "${TEST_APK_PATH}" >/dev/null +echo "Sharding (attempt 0): numShards=${BASE_NUM_SHARDS}, deviceCount=${DEVICE_COUNT}" +echo "Retry config: enabled=${RERUN_FAILED_ENABLED}, maxReruns=${MAX_RERUNS}, retryDevices=${RETRY_DEVICES[*]}" +echo "Allure pull retries: maxAttempts=${MAX_PULL_ATTEMPTS}, baseDelaySec=${PULL_BASE_DELAY_SEC}" +echo "Retry inline transport: partMaxChars=${INLINE_PART_MAX_CHARS}" - 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 +declare -a RERUN_INLINE_PARTS=() - 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 +extract_failed_ids() { + local attempt="$1" + local failed_output="$2" + ATTEMPT_RESULTS_DIR="${ALLURE_RESULTS_ROOT}/attempt-${attempt}" \ + FAILED_TESTS_FILE="${failed_output}" \ + python3 scripts/qa_android_ui_tests/extract_failed_tests.py +} + +build_rerun_inline_parts() { + local list_file="$1" + local max_chars="$2" + local line="" + local current="" + RERUN_INLINE_PARTS=() + + # Split large rerun lists into multiple instrumentation args so we stay below per-arg limits. + while IFS= read -r line || [[ -n "${line}" ]]; do + line="${line%$'\r'}" + [[ -z "${line}" ]] && continue + + if (( ${#line} > max_chars )); then + echo "ERROR: Retry test ID exceeds part size limit (${#line} > ${max_chars}): ${line}" + return 1 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)" + if [[ -z "${current}" ]]; then + current="${line}" + continue 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 + + if (( ${#current} + 1 + ${#line} <= max_chars )); then + current="${current},${line}" + else + RERUN_INLINE_PARTS+=("${current}") + current="${line}" fi + done < "${list_file}" + + if [[ -n "${current}" ]]; then + RERUN_INLINE_PARTS+=("${current}") + fi + + if [[ ${#RERUN_INLINE_PARTS[@]} -eq 0 ]]; then + echo "ERROR: Computed empty rerun inline parts." + return 1 + fi +} + +count_tests_in_list_file() { + local list_file="$1" + if [[ ! -s "${list_file}" ]]; then + echo "0" + return + fi + + wc -l < "${list_file}" | tr -d ' ' +} + +rerun_list_file_for_device() { + local attempt="$1" + local serial="$2" + echo "${STATE_DIR}/attempt-${attempt}-rerun-${serial}.txt" +} + +prepare_retry_assignments() { + local next_attempt="$1" + local failed_list_file="$2" + shift 2 + local devices=("$@") - THIS_SHARD_INDEX="${shard_index}" - if [[ "${NUM_SHARDS}" == "1" ]]; then - THIS_SHARD_INDEX="0" + if [[ ${#devices[@]} -eq 0 ]]; then + echo "ERROR: No devices available for retry attempt ${next_attempt}." + return 1 + fi + + local serial="" + for serial in "${devices[@]}"; do + # Each retry device gets its own explicit list so the workflow owns + # balancing and Android sharding does not reshuffle the failed tests again. + : > "$(rerun_list_file_for_device "${next_attempt}" "${serial}")" + done + + local device_index=0 + local assigned_total=0 + local test_id="" + + # Distribute failed tests one by one across the selected devices. This keeps + # counts balanced without introducing runtime-based scheduling complexity. + while IFS= read -r test_id || [[ -n "${test_id}" ]]; do + test_id="${test_id%$'\r'}" + [[ -z "${test_id}" ]] && continue + + printf '%s\n' "${test_id}" >> "$(rerun_list_file_for_device "${next_attempt}" "${devices[${device_index}]}")" + assigned_total=$((assigned_total + 1)) + device_index=$(((device_index + 1) % ${#devices[@]})) + done < "${failed_list_file}" + + if (( assigned_total == 0 )); then + echo "ERROR: Retry attempt ${next_attempt} has no failed tests to assign." + return 1 + fi + + echo "Retry assignment for attempt ${next_attempt}:" + for serial in "${devices[@]}"; do + local device_list_file + local assigned_count + device_list_file="$(rerun_list_file_for_device "${next_attempt}" "${serial}")" + assigned_count="$(count_tests_in_list_file "${device_list_file}")" + if (( assigned_count == 0 )); then + echo "ERROR: Retry planner assigned zero tests to device ${serial} in attempt ${next_attempt}." + return 1 fi + echo " - ${serial}: ${assigned_count} test(s)" + done +} + +device_reported_zero_tests() { + local attempt="$1" + local serial="$2" + local log_file="${LOG_DIR}/attempt-${attempt}-instrument-${serial}.log" + [[ -f "${log_file}" ]] || return 1 - echo "[${SERIAL}] shardIndex=${THIS_SHARD_INDEX}/${NUM_SHARDS}" + # Zero-test shards are valid for filtered/sharded runs and should not fail the pull step. + grep -qE 'INSTRUMENTATION_STATUS: numtests=0|OK \(0 tests\)|No tests found' "${log_file}" +} - 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 +pull_allure_results_for_attempt() { + local attempt="$1" + shift + local devices=("$@") + + if [[ ${#devices[@]} -eq 0 ]]; then + return + fi - args=() - args+=(-e numShards "${NUM_SHARDS}") - args+=(-e shardIndex "${THIS_SHARD_INDEX}") + local attempt_dir="${ALLURE_RESULTS_ROOT}/attempt-${attempt}" + mkdir -p "${attempt_dir}" - if [[ -n "${RESOLVED_TESTCASE_ID:-}" ]]; then - args+=(-e testCaseId "${RESOLVED_TESTCASE_ID}") + # Support both pull layouts: /allure-results/* and /*. + has_result_files() { + local device_dir="$1" + if compgen -G "${device_dir}/allure-results/*-result.json" >/dev/null; then + return 0 fi - if [[ -n "${RESOLVED_CATEGORY:-}" ]]; then - args+=(-e category "${RESOLVED_CATEGORY}") + if compgen -G "${device_dir}/*-result.json" >/dev/null; then + return 0 fi + return 1 + } + + for serial in "${devices[@]}"; do + local device_dir="${attempt_dir}/${serial}" + local pulled_ok=0 + local pull_try=0 + + for ((pull_try = 1; pull_try <= MAX_PULL_ATTEMPTS; pull_try++)); do + rm -rf "${device_dir}" + mkdir -p "${device_dir}" + + if adb -s "${serial}" pull "/sdcard/googletest/test_outputfiles/allure-results" "${device_dir}" >/dev/null 2>&1; then + if has_result_files "${device_dir}"; then + pulled_ok=1 + break + fi + echo "[${serial}] No Allure result files found after pull (attempt ${attempt}, pullTry ${pull_try}/${MAX_PULL_ATTEMPTS})." + else + echo "[${serial}] Failed to pull allure-results (attempt ${attempt}, pullTry ${pull_try}/${MAX_PULL_ATTEMPTS})." + fi - args+=(-e filter "com.wire.android.tests.support.suite.TaggedFilter") + if (( pull_try < MAX_PULL_ATTEMPTS )); then + local sleep_seconds=$((PULL_BASE_DELAY_SEC * pull_try)) + if (( sleep_seconds > 0 )); then + echo "[${serial}] Retrying allure pull in ${sleep_seconds}s..." + sleep "${sleep_seconds}" + fi + fi + done - if [[ "${IS_UPGRADE:-}" == "true" ]]; then - args+=(-e newApkPath "${NEW_APK_DEVICE_PATH}") - args+=(-e oldApkPath "${OLD_APK_DEVICE_PATH}") + if (( pulled_ok == 0 )); then + # Some shards legitimately execute zero tests; in that case there is nothing to pull. + if device_reported_zero_tests "${attempt}" "${serial}"; then + echo "[${serial}] No Allure result files for attempt ${attempt} because this shard executed zero tests." + continue + fi + echo "ERROR: Failed to pull valid allure-results from ${serial} after ${MAX_PULL_ATTEMPTS} attempt(s)." + return 1 fi + done +} - LOG_FILE="${LOG_DIR}/instrument-${SERIAL}.log" +run_attempt_on_devices() { + local attempt="$1" + local num_shards="$2" + shift 2 + local devices=("$@") + local failed=0 + local pids=() + local shard_index=0 - set +e - ${ADB} shell am instrument -w -r "${args[@]}" "${INSTRUMENTATION}" 2>&1 \ - | sed -u "s/^/[${SERIAL}] /" | tee "${LOG_FILE}" - rc=${PIPESTATUS[0]} - set -e + for serial in "${devices[@]}"; do + ( + set -euo pipefail - if [[ "${rc}" -ne 0 ]]; then - echo "[${SERIAL}] instrumentation command failed (rc=${rc})" - exit 1 + local adb_cmd="adb -s ${serial}" + ${adb_cmd} wait-for-device + ${adb_cmd} install -r -t "${TEST_APK_PATH}" >/dev/null + + local pkgs + pkgs="$(${adb_cmd} 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_cmd} install -r -t "${TEST_SERVICES_APK_PATH}" >/dev/null + fi + + if [[ -n "${ORCHESTRATOR_APK_PATH:-}" ]]; then + local pkgs2 + pkgs2="$(${adb_cmd} 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_cmd} install -r -t "${ORCHESTRATOR_APK_PATH}" >/dev/null || true + fi + fi + + local instr_list instrumentation + instr_list="$(${adb_cmd} 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 + + local this_shard_index="${shard_index}" + if [[ "${num_shards}" == "1" ]]; then + this_shard_index="0" + fi + + echo "[${serial}] attempt=${attempt} shardIndex=${this_shard_index}/${num_shards}" + + local allure_device_dir="/sdcard/googletest/test_outputfiles/allure-results" + ${adb_cmd} shell "rm -rf '${allure_device_dir}' && mkdir -p '${allure_device_dir}'" >/dev/null 2>&1 || true + + local args=() + args+=(-e numShards "${num_shards}") + args+=(-e shardIndex "${this_shard_index}") + args+=(-e filter "com.wire.android.tests.support.suite.TaggedFilter") + + if (( attempt == 0 )); then + if [[ -n "${RESOLVED_TESTCASE_ID:-}" ]]; then + args+=(-e testCaseId "${RESOLVED_TESTCASE_ID}") + fi + if [[ -n "${RESOLVED_CATEGORY:-}" ]]; then + args+=(-e category "${RESOLVED_CATEGORY}") + fi + else + local retry_list_file + local retry_test_count + retry_list_file="$(rerun_list_file_for_device "${attempt}" "${serial}")" + retry_test_count="$(count_tests_in_list_file "${retry_list_file}")" + if (( retry_test_count == 0 )); then + echo "[${serial}] ERROR: Retry attempt ${attempt} has no assigned tests for this device." + exit 1 + fi + + if ! build_rerun_inline_parts "${retry_list_file}" "${INLINE_PART_MAX_CHARS}"; then + echo "[${serial}] ERROR: Failed to build inline rerun arguments for retry attempt ${attempt}." + exit 1 + fi + + # Retry attempts run one shard per device because the workflow already + # prepared a device-specific rerun list for balanced execution. + echo "[${serial}] attempt=${attempt} assignedRetryTests=${retry_test_count}, inlineParts=${#RERUN_INLINE_PARTS[@]}" + args+=(-e enableRerunMode "true") + args+=(-e rerunAttempt "${attempt}") + args+=(-e rerunListInline "${RERUN_INLINE_PARTS[0]}") + if (( ${#RERUN_INLINE_PARTS[@]} > 1 )); then + local part_index=1 + while (( part_index < ${#RERUN_INLINE_PARTS[@]} )); do + args+=(-e "rerunListInlinePart${part_index}" "${RERUN_INLINE_PARTS[part_index]}") + part_index=$((part_index + 1)) + done + fi + fi + + if [[ "${IS_UPGRADE:-}" == "true" ]]; then + args+=(-e newApkPath "${NEW_APK_DEVICE_PATH}") + args+=(-e oldApkPath "${OLD_APK_DEVICE_PATH}") + fi + + local log_file="${LOG_DIR}/attempt-${attempt}-instrument-${serial}.log" + + set +e + ${adb_cmd} shell am instrument -w -r "${args[@]}" "${instrumentation}" 2>&1 \ + | sed -u "s/^/[${serial}] /" | tee "${log_file}" + local rc=${PIPESTATUS[0]} + set -e + + # Keep normal test failures separate from infra failures so retries can still be resolved from Allure. + local has_test_failures=0 + if grep -qE 'FAILURES!!!' "${log_file}"; then + has_test_failures=1 + fi + + if [[ "${rc}" -ne 0 ]]; then + if (( has_test_failures == 1 )); then + echo "[${serial}] instrumentation finished with test failures (rc=${rc}); status will be resolved from Allure results." + else + echo "[${serial}] instrumentation command failed (rc=${rc})" + exit 1 + fi + fi + + if grep -qE 'INSTRUMENTATION_FAILED|INSTRUMENTATION_RESULT: shortMsg=Process crashed|INSTRUMENTATION_RESULT: shortMsg=Process crashed\.' "${log_file}"; then + echo "[${serial}] INFRA_FAIL" + exit 1 + fi + + if (( has_test_failures == 1 )); then + echo "[${serial}] TEST_FAILURE_DETECTED" + else + echo "[${serial}] PASS" + fi + exit 0 + ) & + pids+=("$!") + + shard_index=$((shard_index + 1)) + done + + for pid in "${pids[@]}"; do + if ! wait "${pid}"; then + failed=1 fi + done + + return "${failed}" +} + +# Keep the initial failed list in a separate file so attempt 0 bookkeeping +# does not try to copy a file onto itself before reruns begin. +first_failed_file="${STATE_DIR}/first-attempt-failed.txt" +current_failed_file="" +attempt=0 +overall_infra_failed=0 +declare -a infra_failed_attempts=() - # 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" +while true; do + if (( attempt == 0 )); then + attempt_num_shards="${BASE_NUM_SHARDS}" + attempt_devices=("${DEVICES[@]}") + else + # Retry attempts already have explicit per-device assignments, so each + # device executes exactly one shard containing only its own test list. + attempt_num_shards="1" + attempt_devices=("${RETRY_DEVICES[@]}") + fi + + echo "=== Attempt ${attempt} ===" + attempt_worker_failed=0 + if ! run_attempt_on_devices "${attempt}" "${attempt_num_shards}" "${attempt_devices[@]}"; then + attempt_worker_failed=1 + overall_infra_failed=1 + infra_failed_attempts+=("${attempt}") + fi + + if ! pull_allure_results_for_attempt "${attempt}" "${attempt_devices[@]}"; then + exit 1 + fi + + attempt_failed_file="${STATE_DIR}/attempt-${attempt}-failed.txt" + extract_failed_ids "${attempt}" "${attempt_failed_file}" + failed_count="$(wc -l < "${attempt_failed_file}" | tr -d ' ')" + echo "Attempt ${attempt} failed tests: ${failed_count}" + + if (( attempt == 0 )); then + cp "${attempt_failed_file}" "${first_failed_file}" + fi + current_failed_file="${attempt_failed_file}" + + if (( failed_count == 0 )); then + if (( attempt_worker_failed != 0 )); then + echo "ERROR: Instrumentation failed without detectable failed tests in attempt ${attempt}." exit 1 fi + break + fi - echo "[${SERIAL}] PASS" - exit 0 - ) & - pids+=("$!") - - shard_index=$((shard_index + 1)) -done + if (( attempt >= MAX_RERUNS )); then + break + fi -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 + next_attempt=$((attempt + 1)) + # Limit retry devices to the number of failed tests so every selected device + # receives at least one explicit retry assignment. + failed_count_num=$((10#${failed_count})) + retry_device_count="${#DEVICES[@]}" + if (( failed_count_num < retry_device_count )); then + retry_device_count="${failed_count_num}" + fi + if (( retry_device_count < 1 )); then + retry_device_count=1 fi + RETRY_DEVICES=("${DEVICES[@]:0:${retry_device_count}}") + if ! prepare_retry_assignments "${next_attempt}" "${attempt_failed_file}" "${RETRY_DEVICES[@]}"; then + exit 1 + fi + echo "Prepared rerun attempt ${next_attempt}: tests=${failed_count}, devices=${RETRY_DEVICES[*]}, shardsPerDevice=1." + + attempt="${next_attempt}" done -if [[ "$failed" -ne 0 ]]; then - echo "ERROR: One or more shards failed." +first_failed_count=0 +if [[ -s "${first_failed_file}" ]]; then + first_failed_count="$(wc -l < "${first_failed_file}" | tr -d ' ')" +fi + +final_failed_count=0 +if [[ -n "${current_failed_file}" && -s "${current_failed_file}" ]]; then + final_failed_count="$(wc -l < "${current_failed_file}" | tr -d ' ')" +fi + +recovered_count=0 +if (( first_failed_count > final_failed_count )); then + recovered_count=$((first_failed_count - final_failed_count)) +fi + +if [[ -n "${GITHUB_STEP_SUMMARY:-}" ]]; then + { + echo "### UI Test Retry Summary" + echo "- rerun enabled: ${RERUN_FAILED_ENABLED}" + echo "- max reruns configured: ${MAX_RERUNS}" + echo "- failed on first attempt: ${first_failed_count}" + echo "- passed on rerun: ${recovered_count}" + echo "- failed after retries: ${final_failed_count}" + if (( overall_infra_failed > 0 )); then + echo "- infra shard failures: yes (attempts: ${infra_failed_attempts[*]})" + else + echo "- infra shard failures: no" + fi + } >> "${GITHUB_STEP_SUMMARY}" +fi + +if (( final_failed_count > 0 )); then + echo "ERROR: ${final_failed_count} test(s) still failing after retries." + exit 1 +fi + +if (( overall_infra_failed > 0 )); then + echo "ERROR: Infrastructure-level shard failures occurred in attempt(s): ${infra_failed_attempts[*]}." exit 1 fi diff --git a/scripts/qa_android_ui_tests/validation.sh b/scripts/qa_android_ui_tests/validation.sh index c4bc17ea5bc..e1a93a13228 100755 --- a/scripts/qa_android_ui_tests/validation.sh +++ b/scripts/qa_android_ui_tests/validation.sh @@ -4,7 +4,7 @@ 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 + echo "Usage: $0 {validate-upgrade-inputs|validate-rerun-inputs|resolve-selector-from-tags|print-resolved-values}" >&2 exit 2 } @@ -19,6 +19,28 @@ validate_upgrade_inputs() { fi } +validate_rerun_inputs() { + local enabled="${RERUN_FAILED_ENABLED:-true}" + local count="${RERUN_FAILED_COUNT:-1}" + local count_num=0 + + if [[ ! "${enabled}" =~ ^(true|false)$ ]]; then + echo "ERROR: rerunFailedEnabled must be true or false." + exit 1 + fi + + if [[ ! "${count}" =~ ^[0-9]+$ ]]; then + echo "ERROR: rerunFailedCount must be a whole number >= 0." + exit 1 + fi + + count_num=$((10#${count})) + if (( count_num > 3 )); then + echo "ERROR: rerunFailedCount must be <= 3." + exit 1 + fi +} + resolve_selector_from_tags() { : "${GITHUB_OUTPUT:?GITHUB_OUTPUT not set}" @@ -67,6 +89,9 @@ case "${1:-}" in validate-upgrade-inputs) validate_upgrade_inputs ;; + validate-rerun-inputs) + validate_rerun_inputs + ;; resolve-selector-from-tags) resolve_selector_from_tags ;; diff --git a/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/BaseUiTest.kt b/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/BaseUiTest.kt index 25c250093b9..04768b4d7b9 100644 --- a/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/BaseUiTest.kt +++ b/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/BaseUiTest.kt @@ -23,7 +23,6 @@ import org.koin.test.KoinTest import org.koin.test.KoinTestRule import com.wire.android.tests.support.suite.AllureFailureScreenshotRule import com.wire.android.tests.support.suite.AllureLabelsRule -import com.wire.android.tests.support.suite.AllureLogcatRule import io.qameta.allure.kotlin.Allure /** @@ -46,10 +45,6 @@ abstract class BaseUiTest : KoinTest { @get:Rule val failureScreenshotRule = AllureFailureScreenshotRule() - // logcat on failure - @get:Rule - val logcatRule = AllureLogcatRule(maxLines = 500) - protected fun step(name: String, block: () -> Unit) { Allure.step(name) { block() } } 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 94031139e52..8cfcdb67e66 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 @@ -42,6 +42,7 @@ 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.UiWaitUtils.WaitUtils.waitFor +import uiautomatorutils.UiWaitUtils.waitUntilToastIsDisplayed @RunWith(AndroidJUnit4::class) class FileSharingBetweenTeams : BaseUiTest() { @@ -169,7 +170,7 @@ class FileSharingBetweenTeams : BaseUiTest() { step("Verify connection accepted toast and start a conversation with sender") { pages.connectedUserProfilePage.apply { - assertToastMessageIsDisplayed("Connection request accepted") + waitUntilToastIsDisplayed("Connection request accepted") clickStartConversationButton() } } diff --git a/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/criticalFlows/GroupVideoCall.kt b/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/criticalFlows/GroupVideoCall.kt new file mode 100644 index 00000000000..94523324b20 --- /dev/null +++ b/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/criticalFlows/GroupVideoCall.kt @@ -0,0 +1,470 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +package com.wire.android.tests.core.criticalFlows + +import android.content.Context +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.uiautomator.UiDevice +import backendUtils.BackendClient +import backendUtils.team.TeamHelper +import backendUtils.team.TeamRoles +import backendUtils.team.deleteTeam +import call.CallHelper +import call.CallingManager +import com.wire.android.tests.core.BaseUiTest +import com.wire.android.tests.core.pages.AllPages +import com.wire.android.tests.support.UiAutomatorSetup +import com.wire.android.tests.support.tags.Category +import com.wire.android.tests.support.tags.TestCaseId +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.koin.test.inject +import service.TestServiceHelper +import uiautomatorutils.KeyboardUtils.closeKeyboardIfOpened +import uiautomatorutils.PermissionUtils.grantRuntimePermsForForegroundApp +import uiautomatorutils.UiWaitUtils.WaitUtils.waitFor +import uiautomatorutils.UiWaitUtils.assertToastDisplayed +import uiautomatorutils.UiWaitUtils.iSeeSystemMessage +import uiautomatorutils.UiWaitUtils.waitUntilToastIsDisplayed +import user.usermanager.ClientUserManager +import user.utils.ClientUser +import kotlin.getValue + +@RunWith(AndroidJUnit4::class) +class GroupVideoCall : BaseUiTest() { + private val pages: AllPages by inject() + private lateinit var device: UiDevice + private lateinit var context: Context + private lateinit var backendClient: BackendClient + private lateinit var teamHelper: TeamHelper + private lateinit var testServiceHelper: TestServiceHelper + private val callHelper by lazy { CallHelper() } + private var teamOwnerA: ClientUser? = null + private var teamOwnerB: ClientUser? = null + private lateinit var callingManager: CallingManager + + @Before + fun setUp() { + context = InstrumentationRegistry.getInstrumentation().context + device = UiAutomatorSetup.start(UiAutomatorSetup.APP_INTERNAL) + backendClient = BackendClient.loadBackend("STAGING") + teamHelper = TeamHelper() + testServiceHelper = TestServiceHelper(teamHelper.usersManager) + callHelper.init(teamHelper.usersManager) + callingManager = callHelper.callingManager + grantRuntimePermsForForegroundApp( + device, + android.Manifest.permission.RECORD_AUDIO, + android.Manifest.permission.CAMERA + ) + } + + @After + fun tearDown() { + runCatching { teamOwnerA?.deleteTeam(backendClient) } + runCatching { teamOwnerB?.deleteTeam(backendClient) } + } + + @Suppress("CyclomaticComplexMethod", "LongMethod") + @TestCaseId("TC-8608") + @Category("criticalFlow") + @Test + fun givenGroupCall_whenVideoIsEnabled_thenGroupVideoIsVisible() { + + step("Given backend teams are prepared (WeLikeCalls + IJoinCalls) with owners and members") { + teamHelper.usersManager.createTeamOwnerByAlias( + "user1Name", + "WeLikeCalls", + "en_US", + true, + backendClient, + context + ) + + teamHelper.userXAddsUsersToTeam( + "user1Name", + "user2Name, user3Name", + "WeLikeCalls", + TeamRoles.Member, + backendClient, + context, + true + ) + + teamHelper.usersManager.createTeamOwnerByAlias( + "user4Name", + "IJoinCalls", + "en_US", + true, + backendClient, + context + ) + } + + step("And WeLikeCalls team owner creates GroupVideoCall conversation with team members") { + testServiceHelper.userHasGroupConversationInTeam( + "user1Name", + "GroupVideoCall", + "user2Name, user3Name", + "WeLikeCalls" + ) + } + + step("And participant devices and unique username are prepared for group call") { + testServiceHelper.apply { + addDevice("user4Name", null, "Device2") + addDevice("user3Name", null, "Device1") + runBlocking { + usersSetUniqueUsername("user3Name") + } + } + } + + step("And team owners for WeLikeCalls and IJoinCalls are resolved") { + teamOwnerA = teamHelper.usersManager.findUserBy( + "user1Name", + ClientUserManager.FindBy.NAME_ALIAS + ) + teamOwnerB = teamHelper.usersManager.findUserBy( + "user4Name", + ClientUserManager.FindBy.NAME_ALIAS + ) + } + + step("And conference calling is enabled for WeLikeCalls and IJoinCalls via backdoor") { + runBlocking { + callHelper.enableConferenceCallingFeatureViaBackdoorTeam( + "user1Name", + "WeLikeCalls" + ) + callHelper.enableConferenceCallingFeatureViaBackdoorTeam( + "user4Name", + "IJoinCalls" + ) + } + } + + step("And I see welcome screen before login") { + pages.registrationPage.apply { + assertEmailWelcomePage() + } + } + + step("And I open staging deep link login flow") { + pages.loginPage.apply { + clickStagingDeepLink() + clickProceedButtonOnDeeplinkOverlay() + } + } + + step("And I login as WeLikeCalls team owner") { + pages.loginPage.apply { + enterTeamOwnerLoggingEmail(teamOwnerA?.email ?: "") + clickLoginButton() + enterTeamOwnerLoggingPassword(teamOwnerA?.password ?: "") + clickLoginButton() + } + } + + step("And I complete post-login permission and privacy prompts") { + pages.registrationPage.apply { + waitUntilLoginFlowIsCompleted() + clickAllowNotificationButton() + clickDeclineShareDataAlert() + } + } + + step("And I verify GroupVideoCall conversation is visible and start new conversation flow") { + pages.conversationListPage.apply { + assertGroupConversationVisible("GroupVideoCall") + tapStartNewConversationButton() + } + } + + step("And I open people search to find TeamOwnerB") { + pages.searchPage.apply { + tapSearchPeopleField() + } + } + + step("And I search TeamOwnerB by unique username") { + pages.searchPage.apply { + typeUniqueUserNameInSearchField(teamHelper, "user4Name") + } + } + + step("And I verify TeamOwnerB appears in search results and open profile") { + pages.searchPage.apply { + assertUsernameInSearchResultIs(teamOwnerB?.name ?: "") + tapUsernameInSearchResult(teamOwnerB?.name ?: "") + } + } + + step("And I verify unconnected profile belongs to TeamOwnerB") { + pages.unconnectedUserProfilePage.apply { + assertUserNameInUnconnectedUserProfilePage(teamOwnerB?.name ?: "") + } + } + + step("And I send connection request to TeamOwnerB and verify confirmation toast") { + pages.unconnectedUserProfilePage.apply { + clickConnectionRequestButton() + waitUntilToastIsDisplayed("Connection request sent") + } + } + + step("And I close unconnected profile and return to conversation list") { + pages.unconnectedUserProfilePage.apply { + clickCloseButtonOnUnconnectedUserProfilePage() + } + pages.conversationListPage.apply { + clickCloseButtonOnNewConversationScreen() + } + } + + step("And I verify pending status is visible for TeamOwnerB") { + pages.conversationListPage.apply { + assertConversationNameWithPendingStatusVisibleInConversationList( + teamOwnerB?.name ?: "" + ) + } + } + + step("And TeamOwnerB connection request is accepted via backend") { + runBlocking { + val user = teamHelper.usersManager.findUserByNameOrNameAlias("user4Name") + backendClient.acceptAllIncomingConnectionRequests(user) + } + } + + step("And I verify pending status is removed and GroupVideoCall conversation remains visible") { + pages.conversationListPage.apply { + assertPendingStatusIsNoLongerVisible() + assertGroupConversationVisible("GroupVideoCall") + } + } + + step("And I verify TeamOwnerB conversation is visible and open GroupVideoCall") { + pages.conversationListPage.apply { + assertConversationIsVisibleWithTeamOwner(teamOwnerB?.name ?: "") + tapConversationNameInConversationList("GroupVideoCall") + } + } + + step("And I open GroupVideoCall conversation details") { + pages.conversationViewPage.apply { + clickOnGroupConversationDetails("GroupVideoCall") + } + } + + step("And I open participants tab and start add participant flow") { + pages.groupConversationDetailsPage.apply { + tapOnParticipantsTab() + tapAddParticipantsButton() + } + } + + step("And I select TeamOwnerB from participant suggestions") { + pages.groupConversationDetailsPage.apply { + assertUsernameInSuggestionsListIs(teamOwnerB?.name ?: "") + selectUserInSuggestionList(teamOwnerB?.name ?: "") + tapContinueButton() + } + } + + step("And I verify TeamOwnerB is added to participants list") { + pages.groupConversationDetailsPage.apply { + assertUsernameIsAddedToParticipantsList(teamOwnerB?.name ?: "") + tapCloseButtonOnGroupConversationDetailsPage() + } + } + + step("And I verify system message confirms TeamOwnerB was added") { + iSeeSystemMessage("You added ${teamOwnerB?.name ?: ""} to the conversation") + } + + step("And , , and start instances using Chrome") { + runBlocking { + callHelper.userXStartsInstance( + "user2Name, user3Name, user4Name", + "Chrome" + ) + } + } + + step("And , , and auto-accept the next incoming call") { + runBlocking { + callHelper.userXAcceptsNextIncomingCallAutomatically( + "user2Name, user3Name, user4Name" + ) + } + } + + step("When I start group call from GroupVideoCall conversation") { + pages.conversationViewPage.apply { + iTapStartCallButton() + } + } + + step("Then , , and verify waiting instance status changes to active within 90 seconds") { + runBlocking { + callHelper.userVerifiesCallStatusToUserY( + "user2Name, user3Name, user4Name", + "active", + 90 + ) + } + } + + step("And I see ongoing group call") { + pages.callingPage.apply { + iSeeOngoingGroupCall() + } + } + + step("And I see users , , and in ongoing group call") { + callHelper.iSeeParticipantsInGroupCall("user2Name, user3Name, user4Name") + } + + step("And I turn camera on") { + pages.callingPage.apply { + iTurnCameraOn() + } + } + + step("And users , , and switch video on") { + runBlocking { + val callParticipantsSwitchVideoOn = + teamHelper.usersManager.splitAliases("user2Name, user3Name, user4Name") + callingManager.switchVideoOn(callParticipantsSwitchVideoOn) + } + } + + step("And users , , and verify audio and video are received") { + runBlocking { + val assertCallParticipantsReceiveAudioVideo = + teamHelper.usersManager.splitAliases("user2Name, user3Name, user4Name") + callingManager.verifyReceiveAudioAndVideo(assertCallParticipantsReceiveAudioVideo) + } + } + + step("And I see users , , and in ongoing group video call") { + callHelper.iSeeParticipantsInGroupVideoCall("user2Name, user3Name, user4Name") + } + + step("And I minimise ongoing call to continue conversation actions") { + pages.callingPage.apply { + iMinimiseOngoingCall() + } + } + + step("And I tap ping button in conversation view") { + pages.conversationViewPage.apply { + tapMessageInInputField() + tapPingButton() + } + } + + step("And I see confirmation alert with text \"Are you sure you want to ping 4 people?\" in conversation view") { + pages.conversationViewPage.apply { + iSeePingModalWithText("Are you sure you want to ping 4 people?") + } + } + + step("And I confirm ping and see system message 'You pinged'") { + pages.conversationViewPage.apply { + tapPingButtonModal() + iSeeSystemMessage("You pinged") + closeKeyboardIfOpened() + } + } + + step("And I attempt to start audio recording during ongoing call") { + pages.conversationViewPage.apply { + // `assertToastDisplayed` starts an accessibility-event listener before running `trigger`. + // We must perform the tap/share actions inside `trigger`; otherwise the transient toast can appear and disappear before observation starts. + assertToastDisplayed("You can't record an audio message during a call.", trigger = { + iTapFileSharingButton() + tapSharingOption("Audio") + iTapFileSharingButton() + }) + } + } + + step("And sends audio file message via device Device1 to GroupVideoCall conversation") { + pages.conversationViewPage.apply { + testServiceHelper.contactSendsLocalAudioConversation( + context, + "AudioFile", + "user3Name", + "Device1", + "GroupVideoCall" + ) + } + } + + step("And I see audio file message in conversation") { + pages.conversationViewPage.apply { + assertAudioMessageIsVisible() + } + } + + step("And I see audio playback time starts at zero") { + pages.conversationViewPage.apply { + assertAudioTimeStartsAtZero() + } + } + waitFor(1) + step("And I play audio message") { + pages.conversationViewPage.apply { + clickPlayButtonOnAudioMessage() + } + } + + step("And I pause audio message after 10 seconds") { + pages.conversationViewPage.apply { + waitFor(10) // wait to allow an audio file to play + clickPauseButtonOnAudioMessage() + } + } + + step("Then I verify audio playback time is no longer zero") { + pages.conversationViewPage.apply { + assertAudioTimeIsNotZeroAnymore() + } + } + + step("And I restore ongoing group call and verify users , , and remain connected") { + pages.callingPage.apply { + iRestoreOngoingCall() + } + callHelper.iSeeParticipantsInGroupCall("user2Name, user3Name, user4Name") + } + + step("And I hang up group call and verify call is ended") { + pages.callingPage.apply { + iTapOnHangUpButton() + iDoNotSeeOngoingGroupCall() + } + } + } +} 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 25b14d6e463..f7e586cc4f9 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 @@ -66,8 +66,8 @@ class PersonalAccountLifeCycle : BaseUiTest() { @After fun tearDown() { - teamOwner?.deleteTeam(backendClient) - personalUser?.deleteUser(backendClient) + teamOwner?.deleteTeam(backendClient) + personalUser?.deleteUser(backendClient) } @Suppress("CyclomaticComplexMethod", "LongMethod") @@ -181,7 +181,14 @@ class PersonalAccountLifeCycle : BaseUiTest() { tapConversationNameInConversationList(teamOwner?.name ?: "") } } - + // Wait for the personal 1:1 conversation to fully settle in MLS before sending. + // The 5s settle window specifically reduces intermittent test-service send flakes right after MLS transition. + step("Wait until personal 1:1 conversation is upgraded to MLS") { + pages.conversationViewPage.waitUntilConversationTurnsMls( + timeoutMs = 20_000, + settleAfterDetectedMs = 5_000 + ) + } step("Send message to team owner in 1:1 conversation") { pages.conversationViewPage.apply { typeMessageInInputField("Hello Team Owner") @@ -191,7 +198,7 @@ class PersonalAccountLifeCycle : BaseUiTest() { } step("Receive message from team owner via backend in 1:1 conversation") { - testServiceHelper.userSendMessageToConversationObj( + testServiceHelper.userSendMessageToPersonalMlsConversation( "user1Name", "Hello to you too!", "Device1", @@ -199,6 +206,7 @@ class PersonalAccountLifeCycle : BaseUiTest() { false ) + closeKeyboardIfOpened() pages.conversationViewPage.apply { assertReceivedMessageIsVisibleInCurrentConversation("Hello to you too!") } diff --git a/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/pages/CallingPage.kt b/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/pages/CallingPage.kt index 8de3fba3762..cabcdf213e3 100644 --- a/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/pages/CallingPage.kt +++ b/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/pages/CallingPage.kt @@ -28,8 +28,9 @@ data class CallingPage(private val device: UiDevice) { private val restoreCallButton = UiSelectorParams(text = "RETURN TO CALL") - fun iSeeOngoingGroupCall(): CallingPage { + private val turnCameraOnButton = UiSelectorParams(description = "Turn camera on") + fun iSeeOngoingGroupCall(): CallingPage { try { UiWaitUtils.waitElement(hangUpCallButton) } catch (e: AssertionError) { @@ -47,4 +48,23 @@ data class CallingPage(private val device: UiDevice) { UiWaitUtils.waitElement(restoreCallButton).click() return this } + + fun iTurnCameraOn(): CallingPage { + UiWaitUtils.waitElement(turnCameraOnButton).click() + return this + } + + fun iTapOnHangUpButton(): CallingPage { + UiWaitUtils.waitElement(hangUpCallButton).click() + return this + } + + fun iDoNotSeeOngoingGroupCall(): CallingPage { + try { + UiWaitUtils.waitElement(hangUpCallButton, timeoutMillis = 15_000) + } catch (e: AssertionError) { + return this + } + throw AssertionError("Ongoing call still displayed") + } } 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 c32e4cc8a79..d872f913672 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 @@ -17,9 +17,12 @@ */ package com.wire.android.tests.core.pages +import android.os.SystemClock import androidx.test.platform.app.InstrumentationRegistry import androidx.test.uiautomator.By +import androidx.test.uiautomator.StaleObjectException import androidx.test.uiautomator.UiDevice +import androidx.test.uiautomator.UiSelector import org.junit.Assert import uiautomatorutils.UiSelectorParams import uiautomatorutils.UiWaitUtils @@ -45,17 +48,9 @@ data class ConversationListPage(private val device: UiDevice) { UiSelectorParams(text = conversationName) } 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" - ) - - private val closeNewConversationButton = UiSelectorParams( - description = "Close new conversation view" - ) - - private val userConversationNamePendingLabelString = UiSelectorParams(description = "pending approval of connection request") + private val userConversationNamePendingLabelSelector = + UiSelector().description("pending approval of connection request") fun assertConversationListVisible(): ConversationListPage { val heading = UiWaitUtils.waitElement(conversationListHeading) Assert.assertTrue( @@ -70,9 +65,39 @@ data class ConversationListPage(private val device: UiDevice) { return this } - fun clickSettingsButtonOnMenuEntry(): ConversationListPage { - UiWaitUtils.waitElement(settingsButton).click() - return this + fun clickSettingsButtonOnMenuEntry(timeoutMs: Long = 10_000): ConversationListPage { + val deadline = SystemClock.uptimeMillis() + timeoutMs + var lastMenuClickAt = 0L + + while (SystemClock.uptimeMillis() < deadline) { + if (tryClickIfVisible(settingsButton)) { + return this + } + + val now = SystemClock.uptimeMillis() + if (now - lastMenuClickAt >= 600 && tryClickIfVisible(mainMenuButton)) { + lastMenuClickAt = now + device.waitForIdle(300) + } + + SystemClock.sleep(120) + } + + throw AssertionError("Settings menu entry was not found within ${timeoutMs}ms.") + } + + private fun tryClickIfVisible(selector: UiSelectorParams): Boolean { + val element = UiWaitUtils.findElementOrNull(selector) ?: return false + return try { + if (!element.visibleBounds.isEmpty && element.isEnabled) { + element.click() + true + } else { + false + } + } catch (_: StaleObjectException) { + false + } } fun clickConversationsButtonOnMenuEntry(): ConversationListPage { @@ -153,14 +178,21 @@ data class ConversationListPage(private val device: UiDevice) { return this } - fun tapBackArrowButtonInsideSearchField(): ConversationListPage { - val button = UiWaitUtils.waitElement(backArrowButtonInsideSearchField) - button.click() - return this - } + fun clickCloseButtonOnNewConversationScreen(timeoutMs: Long = 5_000): ConversationListPage { + val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) + + val close = device.findObject( + UiSelector() + .className("android.view.View") + .description("Close new conversation view") + ) + + if (!close.waitForExists(timeoutMs)) { + throw AssertionError("Close button not found within ${timeoutMs}ms") + } + + close.click() - fun clickCloseButtonOnNewConversationScreen(): ConversationListPage { - UiWaitUtils.waitElement(closeNewConversationButton).click() return this } @@ -170,29 +202,43 @@ data class ConversationListPage(private val device: UiDevice) { return this } + @Suppress("ThrowsCount") fun assertConversationNameWithPendingStatusVisibleInConversationList(userName: String): ConversationListPage { + val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) + + // 1) Assert user name is visible try { - UiWaitUtils.waitElement(UiSelectorParams(text = userName)) - } catch (e: AssertionError) { + val userObj = device.findObject(UiSelector().text(userName)) + if (!userObj.waitForExists(10_000)) { + throw AssertionError("User '$userName' is not visible in the conversation list") + } + } catch (e: Throwable) { throw AssertionError("User '$userName' is not visible in the conversation list", e) } - // Assert the 'pending' badge is visible + + // 2) Assert the 'pending' badge is visible try { - UiWaitUtils.waitElement(userConversationNamePendingLabelString) - } catch (e: AssertionError) { + val pendingObj = device.findObject(userConversationNamePendingLabelSelector) + if (!pendingObj.waitForExists(10_000)) { + throw AssertionError("Pending status is not visible for user '$userName'") + } + } catch (e: Throwable) { throw AssertionError("Pending status is not visible for user '$userName'", e) } + return this } fun assertPendingStatusIsNoLongerVisible(): ConversationListPage { - val pending = runCatching { - UiWaitUtils.waitElement(userConversationNamePendingLabelString) - }.getOrNull() + val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) + + UiWaitUtils.waitUntilElementGone( + device = device, + selector = userConversationNamePendingLabelSelector, + timeoutMillis = 10_000, + pollingInterval = 250 + ) - if (pending != null && !pending.visibleBounds.isEmpty) { - throw AssertionError("Pending status is still visible (expected it to be gone)") - } 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 ebbbc7ea35e..361f0fbe5d0 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 @@ -17,6 +17,7 @@ */ package com.wire.android.tests.core.pages +import android.os.SystemClock import androidx.test.platform.app.InstrumentationRegistry import androidx.test.uiautomator.By import androidx.test.uiautomator.UiDevice @@ -55,6 +56,7 @@ data class ConversationViewPage(private val device: UiDevice) { private val saveButton = UiSelectorParams(text = "Save") private val openButton = UiSelectorParams(text = "Open") + private val cancelButton = UiSelectorParams(text = "Cancel") private val downloadButtonOnVideoFile = UiSelectorParams(text = "Tap to download") private val videoDurationLocator = UiSelectorParams(text = "00:03") @@ -63,6 +65,7 @@ data class ConversationViewPage(private val device: UiDevice) { private fun conversationDetails1On1(userName: String) = UiSelector().className("android.widget.TextView").text(userName) + private fun conversationDetailsGroup(userName: String) = UiSelectorParams(text = userName) private val sendButton = UiSelectorParams(description = "Send") private val backButton = UiSelectorParams(description = "Go back to conversation list") @@ -71,6 +74,16 @@ data class ConversationViewPage(private val device: UiDevice) { private val selfDeletingMessageLabel = UiSelectorParams(description = " Self-deleting message") + private val pingButton = UiSelectorParams(description = "Ping") + + private val pingButtonOnModal = UiSelectorParams(text = "Ping") + + private val mlsUpgradeMessageSelectors = listOf( + UiSelectorParams(textContains = "This conversation now uses the new Messaging"), + UiSelectorParams(textContains = "Layer Security (MLS) protocol"), + UiSelectorParams(textContains = "latest version of Wire on your devices") + ) + private fun selfDeleteOption(label: String): UiSelectorParams { return UiSelectorParams(text = label, className = "android.widget.TextView") } @@ -78,6 +91,7 @@ data class ConversationViewPage(private val device: UiDevice) { private fun sharingOption(label: String): UiSelectorParams { return UiSelectorParams(text = label, className = "android.widget.TextView") } + private fun fileWithName(name: String): UiSelectorParams { return UiSelectorParams(text = name) } @@ -174,10 +188,24 @@ data class ConversationViewPage(private val device: UiDevice) { return this } - fun assertFileActionModalIsVisible(): ConversationViewPage { - val modalText = UiWaitUtils.waitElement(modalTextLocator) - assertTrue("The file action modal is not visible.", !modalText.visibleBounds.isEmpty) - return this + fun assertFileActionModalIsVisible(timeoutMs: Long = 8_000): ConversationViewPage { + val modalAnchors = listOf(modalTextLocator, saveButtonLocator, openButton, cancelButton) + val deadline = SystemClock.uptimeMillis() + timeoutMs + + while (SystemClock.uptimeMillis() < deadline) { + val isVisible = modalAnchors + .asSequence() + .mapNotNull(UiWaitUtils::findElementOrNull) + .any { runCatching { !it.visibleBounds.isEmpty }.getOrDefault(false) } + + if (isVisible) { + return this + } + + SystemClock.sleep(150) + } + + throw AssertionError("The file action modal was not visible within ${timeoutMs}ms.") } fun tapSaveButtonOnModal(): ConversationViewPage { @@ -225,8 +253,20 @@ data class ConversationViewPage(private val device: UiDevice) { return this } - fun clickSaveButtonOnDownloadModal(): ConversationViewPage { - UiWaitUtils.waitElement(saveButton).click() + fun clickSaveButtonOnDownloadModal(timeoutMs: Long = 8_000): ConversationViewPage { + val save = UiWaitUtils.waitElement(saveButton, timeoutMillis = timeoutMs) + val bounds = runCatching { save.visibleBounds }.getOrNull() + + runCatching { save.click() } + device.waitForIdle(300) + + val stillVisible = UiWaitUtils.findElementOrNull(saveButton) + ?.let { runCatching { !it.visibleBounds.isEmpty }.getOrDefault(false) } == true + + if (stillVisible && bounds != null && !bounds.isEmpty) { + device.click(bounds.centerX(), bounds.centerY()) + } + return this } @@ -268,7 +308,7 @@ data class ConversationViewPage(private val device: UiDevice) { // Perform fling (fast scroll) to the bottom val success = scrollable.flingToEnd(10) - println("✅ Scrolled to bottom: $success") + println(" Scrolled to bottom: $success") } catch (e: Exception) { println("Failed to scroll: ${e.message}") } @@ -412,6 +452,32 @@ data class ConversationViewPage(private val device: UiDevice) { return this } + fun waitUntilConversationTurnsMls( + timeoutMs: Long = 20_000, + settleAfterDetectedMs: Long = 0 + ): ConversationViewPage { + val deadline = SystemClock.uptimeMillis() + timeoutMs + + while (SystemClock.uptimeMillis() < deadline) { + val mlsMarker = mlsUpgradeMessageSelectors + .asSequence() + .mapNotNull(UiWaitUtils::findElementOrNull) + .firstOrNull { !it.visibleBounds.isEmpty } + + if (mlsMarker != null) { + // MLS banner can appear slightly before the conversation is fully ready for a first outbound message. + if (settleAfterDetectedMs > 0) { + SystemClock.sleep(settleAfterDetectedMs) + } + return this + } + + SystemClock.sleep(200) + } + + throw AssertionError("MLS upgrade system message was not visible within ${timeoutMs}ms.") + } + fun click1On1ConversationDetails(userName: String): ConversationViewPage { val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) val userName = device.findObject(conversationDetails1On1(userName)) @@ -421,6 +487,19 @@ data class ConversationViewPage(private val device: UiDevice) { return this } + fun clickOnGroupConversationDetails(userName: String): ConversationViewPage { + val params = conversationDetailsGroup(userName) + + UiWaitUtils.waitUntilVisible( + params = params, + timeoutMs = 5_000, + errorMessage = "Group conversation details for user '$userName' not visible" + ) + + UiWaitUtils.waitElement(params).click() + return this + } + fun iTapStartCallButton(): ConversationViewPage { UiWaitUtils.waitElement(startCallButton).click() return this @@ -470,4 +549,26 @@ data class ConversationViewPage(private val device: UiDevice) { return this } + + fun tapPingButton(): ConversationViewPage { + UiWaitUtils.waitElement(pingButton).click() + return this + } + + fun tapPingButtonModal(): ConversationViewPage { + UiWaitUtils.waitElement(pingButtonOnModal).click() + return this + } + + fun iSeePingModalWithText(message: String): ConversationViewPage { + val messageSelector = UiSelectorParams(text = message) + + try { + UiWaitUtils.waitElement(messageSelector) + } catch (e: AssertionError) { + throw AssertionError("Message '$message' is not not visible on ping modal.", e) + } + + return this + } } diff --git a/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/pages/DocumentsUIPage.kt b/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/pages/DocumentsUIPage.kt index b8f0061e46e..7ead7afeb08 100644 --- a/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/pages/DocumentsUIPage.kt +++ b/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/pages/DocumentsUIPage.kt @@ -18,15 +18,26 @@ package com.wire.android.tests.core.pages import androidx.test.uiautomator.UiDevice +import androidx.test.uiautomator.StaleObjectException import uiautomatorutils.UiSelectorParams import uiautomatorutils.UiWaitUtils data class DocumentsUIPage(private val device: UiDevice) { private val sendButton = UiSelectorParams(text = "Send") + private val downloadsOption = UiSelectorParams(textContains = "Download") + private val showRootsButton = UiSelectorParams(description = "Show roots") fun iSeeQrCodeImage(fileName: String = "my-test-qr.png"): DocumentsUIPage { val qrCodeImage = UiSelectorParams(text = fileName) + // Picker may open in Recent folder; switch to Downloads so the generated QR file is visible. + if (UiWaitUtils.findElementOrNull(qrCodeImage) == null) { + if (!clickWithRetry(downloadsOption)) { + clickWithRetry(showRootsButton) + clickWithRetry(downloadsOption) + } + } + try { UiWaitUtils.waitElement(qrCodeImage) } catch (e: AssertionError) { @@ -35,6 +46,20 @@ data class DocumentsUIPage(private val device: UiDevice) { return this } + private fun clickWithRetry(selector: UiSelectorParams, attempts: Int = 3): Boolean { + repeat(attempts) { + try { + UiWaitUtils.waitElement(selector, timeoutMillis = 1500).click() + return true + } catch (_: StaleObjectException) { + // Retry with a fresh node. + } catch (_: AssertionError) { + // Selector not present in current picker pane. + } + } + return false + } + fun iOpenDisplayedQrCodeImage(fileName: String = "my-test-qr.png"): DocumentsUIPage { val qrCodeImage = UiSelectorParams(text = fileName) UiWaitUtils.waitElement(qrCodeImage).click() diff --git a/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/pages/GroupConversationDetailsPage.kt b/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/pages/GroupConversationDetailsPage.kt index d705e178c85..86db85d3cf2 100644 --- a/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/pages/GroupConversationDetailsPage.kt +++ b/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/pages/GroupConversationDetailsPage.kt @@ -29,6 +29,14 @@ data class GroupConversationDetailsPage(private val device: UiDevice) { private val removeGroupButton = UiSelectorParams(text = "Remove") + private val participantsTab = UiSelectorParams(text = "PARTICIPANTS") + + private val addParticipantsButton = UiSelectorParams(text = "Add participants") + + private val continueButton = UiSelectorParams(text = "Continue") + + private val closeButtonOnGroupConversationDetailsPage = UiSelectorParams(description = "Close conversation details") + fun tapShowMoreOptionsButton() { UiWaitUtils.waitElement(showMoreOptionsButton).click() } @@ -40,4 +48,73 @@ data class GroupConversationDetailsPage(private val device: UiDevice) { fun tapRemoveGroupButton() { UiWaitUtils.waitElement(removeGroupButton).click() } + + fun tapOnParticipantsTab() { + UiWaitUtils.waitElement(participantsTab).click() + } + + fun tapAddParticipantsButton() { + UiWaitUtils.waitElement(addParticipantsButton).click() + } + + fun assertUsernameInSuggestionsListIs(expectedHandle: String): GroupConversationDetailsPage { + val handleSelector = UiSelectorParams( + className = "android.widget.TextView", + text = expectedHandle + ) + try { + UiWaitUtils.waitElement(params = handleSelector) + } catch (e: AssertionError) { + throw AssertionError( + "Expected user name in suggestion results to be '$expectedHandle' but its not '$expectedHandle'", + e + ) + } + return this + } + + fun selectUserInSuggestionList(expectedHandle: String): GroupConversationDetailsPage { + val handleSelector = UiSelectorParams( + className = "android.widget.TextView", + text = expectedHandle + ) + + val handleTextView = try { + UiWaitUtils.waitElement(params = handleSelector) + } catch (e: AssertionError) { + throw AssertionError( + "Expected user name '$expectedHandle' was not found in suggestion list", + e + ) + } + + handleTextView.parent.click() + + return this + } + + fun tapContinueButton() { + UiWaitUtils.waitElement(continueButton).click() + } + + fun assertUsernameIsAddedToParticipantsList(expectedHandle: String): GroupConversationDetailsPage { + val handleSelector = UiSelectorParams( + className = "android.widget.TextView", + text = expectedHandle + ) + try { + UiWaitUtils.waitElement(params = handleSelector) + } catch (e: AssertionError) { + throw AssertionError( + "Expected user name in participants list results to be '$expectedHandle' but its not '$expectedHandle'", + e + ) + } + return this + } + + fun tapCloseButtonOnGroupConversationDetailsPage(): GroupConversationDetailsPage { + UiWaitUtils.waitElement(closeButtonOnGroupConversationDetailsPage).click() + return this + } } diff --git a/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/pages/RegistrationPage.kt b/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/pages/RegistrationPage.kt index 75c43aa77af..26b7fe58a31 100644 --- a/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/pages/RegistrationPage.kt +++ b/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/pages/RegistrationPage.kt @@ -18,8 +18,10 @@ package com.wire.android.tests.core.pages import androidx.test.espresso.matcher.ViewMatchers.assertThat +import android.os.SystemClock import androidx.test.platform.app.InstrumentationRegistry import androidx.test.uiautomator.By +import androidx.test.uiautomator.StaleObjectException import androidx.test.uiautomator.UiDevice import androidx.test.uiautomator.UiSelector import org.hamcrest.CoreMatchers.`is` @@ -50,10 +52,11 @@ class RegistrationPage(private val device: UiDevice) { private val userNameHelpText = UiSelectorParams(textContains = "At least 2 character") private val editTextClass = By.clazz("android.widget.EditText") private val confirmButton = UiSelectorParams(text = "Confirm") - private val allowNotificationButton = - UiSelectorParams( - resourceId = "com.android.permissioncontroller:id/permission_allow_button" - ) + private val allowNotificationButtons = listOf( + UiSelectorParams(resourceId = "com.android.permissioncontroller:id/permission_allow_button"), + UiSelectorParams(text = "Allow") + ) + private val consentDialogTitle = UiSelectorParams(textContains = "Consent to share user data") private val declineButton = UiSelectorParams(text = "Decline") private val loginButtonGoneSelector = UiSelector().resourceId("loginButton") private val settingUpWireGoneSelector = UiSelector() @@ -69,15 +72,52 @@ class RegistrationPage(private val device: UiDevice) { } fun enterPersonalUserRegistrationEmail(email: String): RegistrationPage { - val emailIputfield = UiWaitUtils.waitElement(emailInputField) - emailIputfield.click() - emailIputfield.text = email - return this + repeat(3) { + try { + UiWaitUtils.waitElement(emailInputField, timeoutMillis = 2_000).click() + UiWaitUtils.waitElement(emailInputField, timeoutMillis = 2_000).text = email + return this + } catch (_: StaleObjectException) { + SystemClock.sleep(150) + } catch (_: AssertionError) { + SystemClock.sleep(150) + } + } + + throw AssertionError("Could not enter registration email: email input field was unstable.") } - fun clickLoginButton(): RegistrationPage { - UiWaitUtils.waitElement(loginButton).click() - return this + @Suppress("NestedBlockDepth") + fun clickLoginButton(timeoutMs: Long = 10_000): RegistrationPage { + val deadline = SystemClock.uptimeMillis() + timeoutMs + var lastError: AssertionError? = null + + while (SystemClock.uptimeMillis() < deadline) { + try { + UiWaitUtils.waitElement(loginButton, timeoutMillis = 1_500).click() + return this + } catch (e: AssertionError) { + lastError = e + try { + val button = UiWaitUtils.findElementOrNull(loginButton) + if (button != null && !button.visibleBounds.isEmpty && button.isEnabled) { + button.click() + return this + } + } catch (_: StaleObjectException) { + // Retry with a freshly resolved node. + } + } catch (_: StaleObjectException) { + // Retry with a freshly resolved node. + } + + SystemClock.sleep(200) + } + + throw AssertionError( + "Login button was not clickable within ${timeoutMs}ms.", + lastError + ) } fun clickCreateAccountButton(): RegistrationPage { @@ -158,13 +198,12 @@ class RegistrationPage(private val device: UiDevice) { val codeInputField = UiWaitUtils.waitElement(UiSelectorParams(className = "android.widget.EditText")) codeInputField.click() codeInputField.text = code + UiWaitUtils.waitElement(userNameInfoText, timeoutMillis = 15_000) return this } fun assertEnterYourUserNameInfoText(): RegistrationPage { - val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) - waitUntilElementGone(device, UiSelector().text("Resend code"), timeoutMillis = 10_000) - val info = UiWaitUtils.waitElement(userNameInfoText) + val info = UiWaitUtils.waitElement(userNameInfoText, timeoutMillis = 15_000) assertTrue("Username info not visible", !info.visibleBounds.isEmpty) return this } @@ -187,14 +226,53 @@ class RegistrationPage(private val device: UiDevice) { return this } - fun clickAllowNotificationButton(): RegistrationPage { - UiWaitUtils.waitElement(allowNotificationButton).click() + fun clickAllowNotificationButton(timeoutMs: Long = 15_000): RegistrationPage { + val deadline = SystemClock.uptimeMillis() + timeoutMs + + while (SystemClock.uptimeMillis() < deadline) { + val button = allowNotificationButtons + .asSequence() + .mapNotNull(UiWaitUtils::findElementOrNull) + .firstOrNull { !it.visibleBounds.isEmpty && it.isEnabled } + + if (button != null) { + button.click() + return this + } + + SystemClock.sleep(200) + } + + // On some devices/runs the permission is already granted and this dialog never appears. return this } - fun clickDeclineShareDataAlert(): RegistrationPage { - UiWaitUtils.waitElement(declineButton).click() - return this + @Suppress("MagicNumber") + fun clickDeclineShareDataAlert(timeoutMs: Long = 10_000): RegistrationPage { + val deadline = SystemClock.uptimeMillis() + timeoutMs + + while (SystemClock.uptimeMillis() < deadline) { + val decline = UiWaitUtils.findElementOrNull(declineButton) + if (decline != null && !decline.visibleBounds.isEmpty && decline.isEnabled) { + val bounds = decline.visibleBounds + runCatching { decline.click() } + val stillVisibleAfterClick = UiWaitUtils.findElementOrNull(declineButton)?.let { !it.visibleBounds.isEmpty } == true + if (stillVisibleAfterClick && !bounds.isEmpty) { + device.click(bounds.centerX(), bounds.centerY()) + } + device.waitForIdle(300) + } + + val dialogVisible = UiWaitUtils.findElementOrNull(consentDialogTitle)?.let { !it.visibleBounds.isEmpty } == true + val declineVisible = UiWaitUtils.findElementOrNull(declineButton)?.let { !it.visibleBounds.isEmpty } == true + if (!dialogVisible && !declineVisible) { + return this + } + + SystemClock.sleep(150) + } + + throw AssertionError("Share data consent alert was not dismissed within ${timeoutMs}ms.") } fun clickAgreeShareDataAlert(): RegistrationPage { @@ -224,7 +302,7 @@ class RegistrationPage(private val device: UiDevice) { fun waitUntilRegistrationFlowIsCompleted(): RegistrationPage { val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) - waitUntilElementGone(device, UiSelector().text("Confirm"), timeoutMillis = 14_000) + waitUntilElementGone(device, UiSelector().text("Confirm"), timeoutMillis = 16_000) 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 1f8e6799495..fe928478f43 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 @@ -19,6 +19,7 @@ package com.wire.android.tests.core.pages import android.content.Intent import android.net.Uri +import android.os.SystemClock import androidx.test.espresso.matcher.ViewMatchers.assertThat import androidx.test.platform.app.InstrumentationRegistry import androidx.test.uiautomator.By @@ -426,15 +427,19 @@ data class SettingsPage(private val device: UiDevice) { return this } - fun assertDeleteAccountConfirmationModalIsNoLongerVisible(): SettingsPage { - val modal = runCatching { - UiWaitUtils.waitElement(deleteAccountConfirmationModal) - }.getOrNull() + fun assertDeleteAccountConfirmationModalIsNoLongerVisible(timeoutMs: Long = 10_000): SettingsPage { + val deadline = SystemClock.uptimeMillis() + timeoutMs - if (modal != null && !modal.visibleBounds.isEmpty) { - throw AssertionError("Delete account confirmation modal is still visible (expected it to be gone)") + while (SystemClock.uptimeMillis() < deadline) { + val modal = UiWaitUtils.findElementOrNull(deleteAccountConfirmationModal) + val isVisible = modal != null && !modal.visibleBounds.isEmpty + if (!isVisible) { + return this + } + SystemClock.sleep(150) } - return this + + throw AssertionError("Delete account confirmation modal is still visible (expected it to be gone)") } fun selectBackupFileInDocumentsUI(teamHelper: TeamHelper, userAlias: String): SettingsPage { diff --git a/tests/testsSupport/src/androidTest/kotlin/com/wire/android/tests/support/suite/AllureLabelsRule.kt b/tests/testsSupport/src/androidTest/kotlin/com/wire/android/tests/support/suite/AllureLabelsRule.kt index ba2b6ff01d6..784f19c01e3 100644 --- a/tests/testsSupport/src/androidTest/kotlin/com/wire/android/tests/support/suite/AllureLabelsRule.kt +++ b/tests/testsSupport/src/androidTest/kotlin/com/wire/android/tests/support/suite/AllureLabelsRule.kt @@ -36,7 +36,6 @@ import org.junit.runners.model.Statement * - @Tag(key="feature","calling") → tag: feature:calling */ class AllureLabelsRule : TestRule { - override fun apply(base: Statement, description: Description): Statement { return object : Statement() { override fun evaluate() { @@ -66,7 +65,7 @@ class AllureLabelsRule : TestRule { Allure.label("tag", "${anno.key}:${anno.value}") } - // Run the actual test + // Run the actual test. base.evaluate() } } diff --git a/tests/testsSupport/src/androidTest/kotlin/com/wire/android/tests/support/suite/RetryContract.kt b/tests/testsSupport/src/androidTest/kotlin/com/wire/android/tests/support/suite/RetryContract.kt new file mode 100644 index 00000000000..c9010833e27 --- /dev/null +++ b/tests/testsSupport/src/androidTest/kotlin/com/wire/android/tests/support/suite/RetryContract.kt @@ -0,0 +1,34 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +package com.wire.android.tests.support.suite + +/** + * Shared retry contract between CI and instrumentation code. + * + * Test IDs must be in this exact format: + * com.example.ClassName#testMethodName + */ +object RetryContract { + const val ARG_ENABLE_RERUN_MODE = "enableRerunMode" + const val ARG_RERUN_ATTEMPT = "rerunAttempt" + const val ARG_RERUN_LIST_PATH = "rerunListPath" + const val ARG_RERUN_LIST_INLINE = "rerunListInline" + const val ARG_RERUN_LIST_INLINE_PART_PREFIX = "rerunListInlinePart" + + const val ALLURE_LABEL_PASSED_ON_RERUN = "passed_on_rerun" +} diff --git a/tests/testsSupport/src/androidTest/kotlin/com/wire/android/tests/support/suite/TaggedFilter.kt b/tests/testsSupport/src/androidTest/kotlin/com/wire/android/tests/support/suite/TaggedFilter.kt index 49f6320aaa9..3c4c49f2327 100644 --- a/tests/testsSupport/src/androidTest/kotlin/com/wire/android/tests/support/suite/TaggedFilter.kt +++ b/tests/testsSupport/src/androidTest/kotlin/com/wire/android/tests/support/suite/TaggedFilter.kt @@ -28,6 +28,7 @@ import com.wire.android.tests.support.tags.Tag import com.wire.android.tests.support.tags.TestCaseId import org.junit.runner.Description import org.junit.runner.manipulation.Filter +import java.io.File /** * JUnit filter used by AndroidJUnitRunner / AllureAndroidJUnitRunner. @@ -36,6 +37,7 @@ import org.junit.runner.manipulation.Filter * - testCaseId * - category * - tagKey / tagValue + * - rerun list (Class#method) when rerun mode is enabled * * Tests that don't match are excluded from the run. */ @@ -48,7 +50,23 @@ class TaggedFilter : Filter() { private val filterCategory: String? = args.getString("category") private val filterTagKey: String? = args.getString("tagKey") private val filterTagValue: String? = args.getString("tagValue") + + private val rerunModeEnabled: Boolean = + args.getString(RetryContract.ARG_ENABLE_RERUN_MODE)?.equals("true", ignoreCase = true) == true + private val rerunAttempt: Int = + args.getString(RetryContract.ARG_RERUN_ATTEMPT)?.toIntOrNull() ?: 0 + private val rerunTestIds: Set by lazy { + loadRerunIds() + } + + private val rerunModeActive: Boolean + get() = rerunModeEnabled && rerunAttempt > 0 + override fun shouldRun(description: Description): Boolean { + if (rerunModeActive) { + return shouldRunInRerunMode(description) + } + // No filters -> include everything if (filterTestCaseId == null && filterCategory == null && @@ -68,6 +86,78 @@ class TaggedFilter : Filter() { return matchesFilters(description) } + private fun shouldRunInRerunMode(description: Description): Boolean { + val children = description.children + if (children.isNotEmpty()) { + return children.any { shouldRunInRerunMode(it) } + } + + if (rerunTestIds.isEmpty()) { + throw IllegalStateException( + "Rerun mode is enabled but no retry tests were provided " + + "(${RetryContract.ARG_RERUN_LIST_PATH}/${RetryContract.ARG_RERUN_LIST_INLINE})." + ) + } + + val testId = toTestId(description) ?: return false + return rerunTestIds.contains(testId) + } + + private fun toTestId(description: Description): String? { + val className = description.className ?: return null + val methodName = description.methodName ?: return null + return "$className#$methodName" + } + + private fun loadRerunIds(): Set { + if (!rerunModeActive) return emptySet() + + val ids = linkedSetOf() + // CI may split large rerun lists into multiple instrumentation args. + parseIds(args.getString(RetryContract.ARG_RERUN_LIST_INLINE)).forEach { ids.add(it) } + loadInlinePartKeys() + .forEach { key -> + parseIds(args.getString(key)).forEach { ids.add(it) } + } + + val path = args.getString(RetryContract.ARG_RERUN_LIST_PATH)?.trim().orEmpty() + if (path.isNotEmpty()) { + try { + parseIds(File(path).readText()).forEach { ids.add(it) } + } catch (_: Throwable) { + throw IllegalStateException("Failed to read rerun list from path: '$path'") + } + } + return ids + } + + private fun loadInlinePartKeys(): List { + val prefix = RetryContract.ARG_RERUN_LIST_INLINE_PART_PREFIX + return args.keySet() + .filter { it.startsWith(prefix) } + .sortedWith( + compareBy { key -> + key.removePrefix(prefix).toIntOrNull() ?: Int.MAX_VALUE + }.thenBy { key -> key } + ) + } + + private fun parseIds(raw: String?): Set { + if (raw.isNullOrBlank()) return emptySet() + return raw + .split(",", "\n", "\r") + .map { it.trim() } + .filter { it.isNotEmpty() } + .onEach { value -> + if (!value.contains('#')) { + throw IllegalStateException( + "Invalid rerun test id '$value'. Expected format: ClassName#methodName." + ) + } + } + .toSet() + } + private fun matchesFilters(description: Description): Boolean { val annos = description.annotations @@ -108,6 +198,7 @@ class TaggedFilter : Filter() { override fun describe(): String { return "TaggedFilter(testCaseId=$filterTestCaseId, " + - "category=$filterCategory, tagKey=$filterTagKey, tagValue=$filterTagValue)" + "category=$filterCategory, tagKey=$filterTagKey, tagValue=$filterTagValue, " + + "rerunModeEnabled=$rerunModeEnabled, rerunAttempt=$rerunAttempt)" } } diff --git a/tests/testsSupport/src/androidTest/kotlin/com/wire/android/tests/support/suite/TaggedTestRunner.kt b/tests/testsSupport/src/androidTest/kotlin/com/wire/android/tests/support/suite/TaggedTestRunner.kt index 7451df47466..a7509ca4fbe 100644 --- a/tests/testsSupport/src/androidTest/kotlin/com/wire/android/tests/support/suite/TaggedTestRunner.kt +++ b/tests/testsSupport/src/androidTest/kotlin/com/wire/android/tests/support/suite/TaggedTestRunner.kt @@ -33,11 +33,20 @@ class TaggedTestRunner : AllureAndroidJUnitRunner() { val category = arguments.getString("category") val tagKey = arguments.getString("tagKey") val tagValue = arguments.getString("tagValue") + val rerunMode = arguments.getString(RetryContract.ARG_ENABLE_RERUN_MODE) + val rerunAttempt = arguments.getString(RetryContract.ARG_RERUN_ATTEMPT) + val rerunListPath = arguments.getString(RetryContract.ARG_RERUN_LIST_PATH) + val rerunListInline = arguments.getString(RetryContract.ARG_RERUN_LIST_INLINE) + val rerunListInlinePartCount = arguments.keySet() + .count { key -> key.startsWith(RetryContract.ARG_RERUN_LIST_INLINE_PART_PREFIX) } Log.i( "TaggedTestRunner", "onCreate called. " + - "testCaseId=$filterId, category=$category, tagKey=$tagKey, tagValue=$tagValue" + "testCaseId=$filterId, category=$category, tagKey=$tagKey, tagValue=$tagValue, " + + "rerunMode=$rerunMode, rerunAttempt=$rerunAttempt, " + + "rerunListPath=$rerunListPath, rerunListInlineLength=${rerunListInline?.length ?: 0}, " + + "rerunListInlinePartCount=$rerunListInlinePartCount" ) super.onCreate(arguments) diff --git a/tests/testsSupport/src/main/call/CallHelper.kt b/tests/testsSupport/src/main/call/CallHelper.kt index 8e8168356bd..e9af8566851 100644 --- a/tests/testsSupport/src/main/call/CallHelper.kt +++ b/tests/testsSupport/src/main/call/CallHelper.kt @@ -101,4 +101,33 @@ class CallHelper { suspend fun userVerifiesAudio(callees: String) { callingManager.verifySendAndReceiveAudio(callees) } + + fun iSeeParticipantsInGroupVideoCall(participants: String) { + // 1. Resolve aliases into real usernames + val resolvedParticipants = usersManager.replaceAliasesOccurrences( + participants, + ClientUserManager.FindBy.NAME_ALIAS + ) + + // 2. Split into individual names and check each one + resolvedParticipants + .split(",") + .map { it.trim() } + .forEach { participant -> + try { + // In the video grid, each tile shows the participant name as a TextView label. + UiWaitUtils.waitElement( + UiSelectorParams( + text = participant + + ) + ) + } catch (e: AssertionError) { + throw AssertionError( + "User '$participant' is not visible in the ongoing group video call (name label not found).", + e + ) + } + } + } } diff --git a/tests/testsSupport/src/main/call/CallingManager.kt b/tests/testsSupport/src/main/call/CallingManager.kt index 178658ac638..805969609db 100644 --- a/tests/testsSupport/src/main/call/CallingManager.kt +++ b/tests/testsSupport/src/main/call/CallingManager.kt @@ -456,8 +456,19 @@ class CallingManager(private val usersManager: ClientUserManager) { userNames.forEach { name -> val user = usersManager.findUserByNameOrNameAlias(name) val flowsBefore = safeGetFlows(user) - for (flowBefore in flowsBefore) - assertPositiveFlowChange(user, flowBefore, audioRecv = true, videoRecv = true) + + check(flowsBefore.isNotEmpty()) { + "Found no flows for ${user.name}" + } + + for (flowBefore in flowsBefore) { + assertPositiveFlowChange( + user, + flowBefore, + audioRecv = true, + videoRecv = true + ) + } } } diff --git a/tests/testsSupport/src/main/res/raw/test.m4a b/tests/testsSupport/src/main/res/raw/test.m4a index ab94045950b..3937ef69258 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/service/TestService.kt b/tests/testsSupport/src/main/service/TestService.kt index 49a5a966d64..42d0fa484eb 100644 --- a/tests/testsSupport/src/main/service/TestService.kt +++ b/tests/testsSupport/src/main/service/TestService.kt @@ -249,7 +249,9 @@ class TestService(private val baseUri: String, private val testName: String) { put("expectsReadConfirmation", true) } put("text", params.text) - put("buttons", params.buttons) + if (params.buttons.length() > 0) { + put("buttons", params.buttons) + } put("legalHoldStatus", params.legalHoldStatus) } val result = sendHttpRequest(connection, requestBody) @@ -699,7 +701,9 @@ class TestService(private val baseUri: String, private val testName: String) { } private fun JSONObject.addButtonsIfPresent(buttons: JSONArray?) { - buttons?.let { put("buttons", it) } + if (buttons != null && buttons.length() > 0) { + put("buttons", buttons) + } } private fun JSONObject.addMessageTimerIfNeeded(messageTimer: Duration) { diff --git a/tests/testsSupport/src/main/service/TestServiceHelper.kt b/tests/testsSupport/src/main/service/TestServiceHelper.kt index ac62801f555..2a04ca12818 100644 --- a/tests/testsSupport/src/main/service/TestServiceHelper.kt +++ b/tests/testsSupport/src/main/service/TestServiceHelper.kt @@ -65,12 +65,22 @@ class TestServiceHelper( } } + private fun backendFor(user: ClientUser): BackendClient { + val backendName = user.backendName + return if (backendName.isNullOrBlank()) { + BackendClient.getDefault() + ?: throw IllegalStateException("No default backend configured for user '${user.name}'.") + } else { + BackendClient.loadBackend(backendName) + } + } + fun getSelfDeletingMessageTimeout(userAlias: String, conversationName: String): Duration { val user = usersManager.findUserByNameOrNameAlias(userAlias) // Only team users support enforced self-deleting messages user.teamId?.let { - val settings = BackendClient.loadBackend(user.backendName.orEmpty()).getSelfDeletingMessagesSettings(user) + val settings = backendFor(user).getSelfDeletingMessagesSettings(user) if (settings.getString("status") == "enabled") { val timeoutInSeconds = settings @@ -87,20 +97,39 @@ class TestServiceHelper( } } - // Personal user or team user without set enforced self-deleting message setting - + // Personal user or team user without enforced setting val resolvedConversationName = usersManager.replaceAliasesOccurrences( conversationName, ClientUserManager.FindBy.NAME_ALIAS ) - val messageTimerMillis = toConvoObjPersonal(user, resolvedConversationName).messageTimerInMilliseconds - if (messageTimerMillis > 0) { - return Duration.ofMillis(messageTimerMillis.toLong()) + val conversationMessageTimerMillis = getConversationMessageTimer(user, resolvedConversationName) + if (conversationMessageTimerMillis > 0) { + return Duration.ofMillis(conversationMessageTimerMillis.toLong()) } - // Otherwise check for local/client-side self-deleting message timeout - return Duration.ofSeconds(Long.MAX_VALUE) + return Duration.ofMillis(Int.MAX_VALUE.toLong()) // ~24.8 days, safe int millis + } + + private fun getConversationMessageTimer(user: ClientUser, conversationName: String): Int { + val isPersonalConversationName = runCatching { + // If this succeeds, conversationName is a user name/alias (1:1 style like "user4Name") + usersManager.findUserByNameOrNameAlias(conversationName) + }.isSuccess + + val conversation = if (isPersonalConversationName) { + // Personal first, fallback to group just in case + runCatching { toConvoObjPersonal(user, conversationName) } + .recoverCatching { toConvoObj(user, conversationName) } + .getOrThrow() + } else { + // Group first, fallback to personal just in case + runCatching { toConvoObj(user, conversationName) } + .recoverCatching { toConvoObjPersonal(user, conversationName) } + .getOrThrow() + } + + return conversation.messageTimerInMilliseconds } fun contactSendsLocalAudioPersonalMLSConversation( @@ -133,6 +162,34 @@ class TestServiceHelper( ) } + fun contactSendsLocalAudioConversation( + context: Context, + fileName: String, + senderAlias: String, + deviceName: String, + dstConvoName: String + ) { + val audio = getRawResourceAsFile(context, R.raw.test, fileName) + val conversation = toConvoObj(toClientUser(senderAlias), dstConvoName) + + if (audio?.exists() != true) { + throw Exception("Audio file not found") + } + + val convoId = conversation.qualifiedID.id + val convoDomain = conversation.qualifiedID.domain + + testServiceClient.sendFile( + toClientUser(senderAlias), + deviceName, + convoId, + convoDomain, + getSelfDeletingMessageTimeout(senderAlias, dstConvoName), + audio.absolutePath.orEmpty(), + "audio/mp4" + ) + } + fun userXAddedContactsToGroupChat( userAsNameAlias: String, contactsToAddNameAliases: String, @@ -144,7 +201,7 @@ class TestServiceHelper( .splitAliases(contactsToAddNameAliases) .map { toClientUser(it) } - BackendClient.loadBackend(userAs.backendName.orEmpty()).addUsersToGroupConversation( + backendFor(userAs).addUsersToGroupConversation( asUser = userAs, contacts = contactsToAdd, conversation = toConvoObj(userAs, chatName) @@ -246,21 +303,22 @@ class TestServiceHelper( } fun toConvoObjPersonal(owner: ClientUser, convoName: String): Conversation { - val convoName = usersManager.replaceAliasesOccurrences(convoName, ClientUserManager.FindBy.NAME_ALIAS) - val backend = BackendClient.loadBackend(owner.backendName.orEmpty()) - return backend.getPersonalConversationByName(owner, convoName) + val seekName = usersManager.findUserByNameOrNameAlias(convoName).name + ?: throw NoSuchElementException("User '$convoName' does not have a resolvable display name.") + val backend = backendFor(owner) + return backend.getPersonalConversationByName(owner, seekName) } fun toConvoObj(owner: ClientUser, convoName: String): Conversation { val convoName = usersManager.replaceAliasesOccurrences(convoName, ClientUserManager.FindBy.NAME_ALIAS) - val backend = BackendClient.loadBackend(owner.backendName.orEmpty()) + val backend = backendFor(owner) return backend.getConversationByName(owner, convoName) } suspend fun usersSetUniqueUsername(userNameAliases: String) { usersManager.splitAliases(userNameAliases).forEach { userNameAlias -> val user = toClientUser(userNameAlias) - val backend = BackendClient.loadBackend(user.backendName.orEmpty()) + val backend = backendFor(user) backend.updateUniqueUsername( user, user.uniqueUsername.orEmpty() @@ -270,7 +328,7 @@ class TestServiceHelper( fun connectionRequestIsSentTo(userFromNameAlias: String, usersToNameAliases: String) { val userFrom = toClientUser(userFromNameAlias) - val backend = BackendClient.loadBackend(userFrom.backendName.orEmpty()) + val backend = backendFor(userFrom) val usersTo = usersManager .splitAliases(usersToNameAliases) .map(this::toClientUser) @@ -286,11 +344,11 @@ class TestServiceHelper( verificationCode: String? = null, deviceName: String? = null, ) { - val developmentApiEnabled = - BackendClient.loadBackend(toClientUser(ownerAlias).backendName.orEmpty()).isDevelopmentApiEnabled(toClientUser(ownerAlias)) + val owner = toClientUser(ownerAlias) + val developmentApiEnabled = backendFor(owner).isDevelopmentApiEnabled(owner) try { testServiceClient.login( - toClientUser(ownerAlias), + owner, verificationCode, deviceName, developmentApiEnabled @@ -299,7 +357,7 @@ class TestServiceHelper( try { TimeUnit.SECONDS.sleep(300) testServiceClient.login( - toClientUser(ownerAlias), + owner, verificationCode, deviceName, developmentApiEnabled @@ -324,21 +382,17 @@ class TestServiceHelper( .map(this::toClientUser) } - val backend = if (chatOwner.backendName.isNullOrEmpty()) { - BackendClient.getDefault() - } else { - BackendClient.loadBackend(chatOwner.backendName.orEmpty()) - } + val backend = backendFor(chatOwner) runBlocking { - val dstTeam = backend?.getTeamByName(chatOwner, teamName) - backend?.createTeamConversation(chatOwner, participants, chatName, dstTeam!!) + val dstTeam = backend.getTeamByName(chatOwner, teamName) + backend.createTeamConversation(chatOwner, participants, chatName, dstTeam) } } fun isSendReadReceiptEnabled(userNameAlias: String): Boolean { val user = toClientUser(userNameAlias) - val backend = BackendClient.loadBackend(user.backendName.orEmpty()) + val backend = backendFor(user) val json = runBlocking { backend.getPropertyValues(user) } @@ -375,7 +429,7 @@ class TestServiceHelper( fun syncUserIdsForUsersCreatedThroughIdP(ownerNameAlias: String, user: ClientUser) { user.getUserIdThroughOwner = Callable { val asUser = toClientUser(ownerNameAlias) - val backend = BackendClient.loadBackend(asUser.backendName.orEmpty()) + val backend = backendFor(asUser) val teamMembers = backend.getTeamMembers(asUser) for (member in teamMembers) { @@ -407,10 +461,16 @@ class TestServiceHelper( ) { val clientUser = toClientUser(senderAlias) val conversation = toConvoObj(clientUser, dstConvoName) - sendMessageInternal(clientUser, conversation, msg, deviceName, isSelfDeleting) + sendMessageInternal( + clientUser = clientUser, + conversation = conversation, + msg = msg, + deviceName = deviceName, + timeout = resolveMessageTimeout(senderAlias, dstConvoName, isSelfDeleting) + ) } - fun userSendMessageToConversationObj( + fun userSendMessageToPersonalMlsConversation( senderAlias: String, msg: String, deviceName: String, @@ -419,7 +479,27 @@ class TestServiceHelper( ) { val clientUser = toClientUser(senderAlias) val conversation = toConvoObjPersonal(clientUser, dstConvoName) - sendMessageInternal(clientUser, conversation, msg, deviceName, isSelfDeleting) + sendMessageInternal( + clientUser = clientUser, + conversation = conversation, + msg = msg, + deviceName = deviceName, + timeout = resolveMessageTimeout(senderAlias, dstConvoName, isSelfDeleting) + ) + } + + private fun resolveMessageTimeout( + senderAlias: String, + dstConvoName: String, + isSelfDeleting: Boolean + ): Duration { + return if (isSelfDeleting) { + Duration.ofSeconds(1000) + } else { + getSelfDeletingMessageTimeout(senderAlias, dstConvoName).let { timeout -> + if (timeout == Duration.ofMillis(Int.MAX_VALUE.toLong())) Duration.ZERO else timeout + } + } } fun userXSharesLocationTo( @@ -450,7 +530,7 @@ class TestServiceHelper( conversation: Conversation, msg: String, deviceName: String, - isSelfDeleting: Boolean + timeout: Duration ) { val convoId = conversation.qualifiedID.id val convoDomain = conversation.qualifiedID.domain @@ -461,17 +541,24 @@ class TestServiceHelper( else -> false } - testServiceClient.sendText( - SendTextParams( - owner = clientUser, - deviceName = deviceName, - convoDomain = convoDomain, - convoId = convoId, - timeout = if (isSelfDeleting) Duration.ofSeconds(1000) else Duration.ZERO, - expectsReadConfirmation = expReadConfirm, - text = msg, - legalHoldStatus = LegalHoldStatus.DISABLED.code, + try { + testServiceClient.sendText( + SendTextParams( + owner = clientUser, + deviceName = deviceName, + convoDomain = convoDomain, + convoId = convoId, + timeout = timeout, + expectsReadConfirmation = expReadConfirm, + text = msg, + legalHoldStatus = LegalHoldStatus.DISABLED.code, + ) ) - ) + } catch (e: Throwable) { + throw AssertionError( + "Failed to send message '$msg' to conversationId='$convoId' for user='${clientUser.name}' on device '$deviceName'.", + e + ) + } } } diff --git a/tests/testsSupport/src/main/uiautomatorutils/FileUtils.kt b/tests/testsSupport/src/main/uiautomatorutils/FileUtils.kt index fe2a76a0990..abb8b1a3d04 100644 --- a/tests/testsSupport/src/main/uiautomatorutils/FileUtils.kt +++ b/tests/testsSupport/src/main/uiautomatorutils/FileUtils.kt @@ -1,6 +1,9 @@ +import android.content.ContentValues import android.graphics.Bitmap -import android.os.Environment import android.graphics.Color +import android.os.Build +import android.os.Environment +import android.provider.MediaStore import androidx.test.platform.app.InstrumentationRegistry import androidx.test.uiautomator.UiDevice import com.google.zxing.BarcodeFormat @@ -38,10 +41,14 @@ fun deleteDownloadedFilesContaining(keyword: String, dir: String = DOWNLOAD_DIR) } } -@Suppress("MagicNumber") +@Suppress("MagicNumber", "ThrowsCount", "TooGenericExceptionCaught") object QrCodeTestUtils { + /** + * Generates a QR PNG and stores it in the device Downloads folder so test flows can pick it from DocumentsUI. + */ fun createQrImageInDeviceDownloadsFolder(text: String): File { val size = 500 + val fileName = "$text.png" val bitMatrix = QRCodeWriter().encode( text, BarcodeFormat.QR_CODE, @@ -55,17 +62,45 @@ object QrCodeTestUtils { } } - val downloads = Environment.getExternalStoragePublicDirectory( - Environment.DIRECTORY_DOWNLOADS - ) + val downloads = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS) if (!downloads.exists()) downloads.mkdirs() - val file = File(downloads, "$text.png") + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + // Android Q+ requires writing shared files through MediaStore (scoped storage). + val resolver = InstrumentationRegistry.getInstrumentation().targetContext.contentResolver + val values = ContentValues().apply { + put(MediaStore.MediaColumns.DISPLAY_NAME, fileName) + put(MediaStore.MediaColumns.MIME_TYPE, "image/png") + put(MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_DOWNLOADS) + // Keep the item hidden until writing is complete. + put(MediaStore.MediaColumns.IS_PENDING, 1) + } + + val uri = resolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, values) + ?: throw IOException("Failed to create MediaStore entry for $fileName") + + try { + resolver.openOutputStream(uri)?.use { output -> + bitmap.compress(Bitmap.CompressFormat.PNG, 100, output) + } ?: throw IOException("Failed to open output stream for $uri") + // Publish file to Downloads once fully written. + values.clear() + values.put(MediaStore.MediaColumns.IS_PENDING, 0) + resolver.update(uri, values, null, null) + } catch (e: Exception) { + // Avoid leaving broken entries in MediaStore on partial write failures. + resolver.delete(uri, null, null) + throw e + } + + return File(downloads, fileName) + } + + val file = File(downloads, fileName) FileOutputStream(file).use { fos -> bitmap.compress(Bitmap.CompressFormat.PNG, 100, fos) } - return file } } diff --git a/tests/testsSupport/src/main/uiautomatorutils/UiWaitUtils.kt b/tests/testsSupport/src/main/uiautomatorutils/UiWaitUtils.kt index f251e3f2a67..4925f45bad2 100644 --- a/tests/testsSupport/src/main/uiautomatorutils/UiWaitUtils.kt +++ b/tests/testsSupport/src/main/uiautomatorutils/UiWaitUtils.kt @@ -19,6 +19,7 @@ package uiautomatorutils import android.graphics.Rect import android.os.SystemClock +import android.view.accessibility.AccessibilityEvent import androidx.test.platform.app.InstrumentationRegistry import androidx.test.uiautomator.By import androidx.test.uiautomator.BySelector @@ -29,6 +30,8 @@ import androidx.test.uiautomator.UiSelector import androidx.test.uiautomator.Until import java.io.IOException import java.util.regex.Pattern +import junit.framework.TestCase.assertTrue + private const val TIMEOUT_IN_MILLISECONDS = 10000L data class UiSelectorParams( @@ -100,7 +103,7 @@ object UiWaitUtils { device.waitForIdle(500) // 2) Stabilize: refetch until bounds are stable & usable - val end = SystemClock.uptimeMillis() + 1_500 + val end = SystemClock.uptimeMillis() + 3_000 var lastBounds: Rect? = null while (SystemClock.uptimeMillis() < end) { @@ -175,4 +178,74 @@ object UiWaitUtils { } } } + + fun waitUntilVisible( + params: UiSelectorParams, + timeoutMs: Long = TIMEOUT_IN_MILLISECONDS, + errorMessage: String + ) { + val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) + try { + val sel = params.toBySelector() + if (!device.wait(Until.hasObject(sel), timeoutMs)) { + throw AssertionError() + } + } catch (e: AssertionError) { + throw AssertionError(errorMessage, e) + } + } + + fun waitUntilToastIsDisplayed( + message: String, + timeoutMs: Long = 5_000 + ) { + waitUntilVisible( + params = UiSelectorParams(textContains = message), + timeoutMs = timeoutMs, + errorMessage = "Toast message containing '$message' was not displayed within ${timeoutMs}ms." + ) + } + + fun iSeeSystemMessage( + message: String, + timeoutMs: Long = 5_000 + ) { + waitUntilVisible( + params = UiSelectorParams(textContains = message), + timeoutMs = timeoutMs, + errorMessage = "System message containing '$message' was not displayed within ${timeoutMs}ms." + ) + } + + @Suppress("MagicNumber") + fun assertToastDisplayed(text: String, trigger: () -> Unit, timeoutMs: Long = 5_000L) { + var toastDisplayed = false + val startTimeMs = System.currentTimeMillis() + + val uiAutomation = InstrumentationRegistry.getInstrumentation().uiAutomation + + uiAutomation.setOnAccessibilityEventListener { event -> + if (event.eventType == AccessibilityEvent.TYPE_NOTIFICATION_STATE_CHANGED) { + val className = event.className?.toString().orEmpty() + val eventText = event.text?.joinToString(" ").orEmpty() + + if (className.contains("android.widget.Toast") && eventText.contains(text, ignoreCase = true)) { + toastDisplayed = true + } + } + } + + try { + // IMPORTANT: trigger AFTER listener is set + trigger() + + while (!toastDisplayed && System.currentTimeMillis() - startTimeMs < timeoutMs) { + Thread.sleep(50) + } + + assertTrue("Toast with text '$text' not found within ${timeoutMs}ms", toastDisplayed) + } finally { + uiAutomation.setOnAccessibilityEventListener(null) + } + } }