Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion .github/actions/android-emulator-test/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,12 @@ inputs:
description: Build directory (for diagnostics collection)
required: false
default: ${{ runner.temp }}/build
androidtest-apk-path:
description: >-
Path to the instrumentation (androidTest) APK. When set, the emulator step
installs it alongside the app APK and runs `am instrument` after the boot test.
required: false
default: ''

runs:
using: composite
Expand Down Expand Up @@ -59,7 +65,8 @@ runs:
script: |
timeout 20s adb start-server >/dev/null 2>&1 || true
timeout 180s adb wait-for-device || (echo "::error::adb wait-for-device timed out before boot test." && exit 1)
python3 .github/scripts/android_boot_test.py --apk "${{ inputs.apk-path }}" --package ${{ inputs.package }} --timeout 120 --stability-window 20 --adb-ready-timeout 180 --install-retries 4 --install-retry-delay 5 --launch-retries 2 --log-output /tmp/qgc_emulator_boot.log
python3 .github/scripts/android_boot_test.py --apk "${{ inputs.apk-path }}" --package "${{ inputs.package }}" --timeout 120 --stability-window 20 --adb-ready-timeout 180 --install-retries 4 --install-retry-delay 5 --launch-retries 2 --log-output /tmp/qgc_emulator_boot.log
python3 .github/scripts/android_instrumentation_test.py --package "${{ inputs.package }}" --androidtest-apk "${{ inputs.androidtest-apk-path }}" --log-output /tmp/qgc_instrumentation.log

- name: Collect Emulator Diagnostics
if: failure()
Expand All @@ -71,6 +78,7 @@ runs:
--out-dir "${RUNNER_TEMP}/emulator-diagnostics" \
--build-dir "${BUILD_DIR}" \
--boot-log /tmp/qgc_emulator_boot.log
cp /tmp/qgc_instrumentation.log "${RUNNER_TEMP}/emulator-diagnostics/" 2>/dev/null || true
ls -la "${RUNNER_TEMP}/emulator-diagnostics" || true

- name: Upload Emulator Diagnostics
Expand Down
34 changes: 31 additions & 3 deletions .github/actions/install-dependencies/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,10 @@ inputs:
description: 'Additional packages to install (space-separated, Linux only)'
required: false
default: ''
package-category:
description: 'Restrict the Linux host apt set to a single category (e.g. core); empty installs all'
required: false
default: ''

runs:
using: composite
Expand Down Expand Up @@ -73,26 +77,50 @@ runs:
if: runner.os == 'Linux'
id: linux-deps
shell: bash
env:
INPUT_PACKAGE_CATEGORY: ${{ inputs.package-category }}
run: python3 "${GITHUB_WORKSPACE}/.github/scripts/install_dependencies_helper.py" print-packages

- name: Enable universe repository (Linux)
if: runner.os == 'Linux'
shell: bash
run: python3 "${GITHUB_WORKSPACE}/.github/scripts/install_dependencies_helper.py" enable-universe

# `sudo timeout` (not nick-fields/retry) bounds each attempt: that action runs as `runner`
# and dies with EPERM trying to kill root-owned apt on timeout, so it can't retry. Running
# the timeout under sudo lets root kill apt; the loop resumes from apt's archive cache and
# `dpkg --configure -a` recovers a half-applied attempt. Bounds a throttled mirror without
# the unbounded grind composite steps would otherwise allow (no native timeout-minutes).
- name: Install apt packages (Linux)
if: runner.os == 'Linux'
shell: bash
env:
APT_PACKAGES: ${{ steps.linux-deps.outputs.packages }}
run: |
sudo apt-get -o Acquire::Retries=3 update -qq
# shellcheck disable=SC2086
sudo apt-get -o Acquire::Retries=3 install -y --no-install-recommends $APT_PACKAGES
set -uo pipefail
apt_opts=(-o DPkg::Lock::Timeout=300 -o Acquire::Retries=3)
sudo timeout 300 apt-get "${apt_opts[@]}" update -qq \
|| echo "::warning::apt update timed out/failed; proceeding with cached lists"
for attempt in 1 2 3; do
echo "::group::apt install attempt ${attempt}"
sudo dpkg --configure -a || true
# shellcheck disable=SC2086
if sudo timeout 720 apt-get "${apt_opts[@]}" install -y --no-install-recommends $APT_PACKAGES; then
echo "::endgroup::"
exit 0
fi
echo "::endgroup::"
echo "::warning::apt install attempt ${attempt} failed or timed out; retrying"
sleep 15
done
echo "::error::apt install failed after 3 attempts"
exit 1

- name: Fix apt alternatives (Linux)
if: runner.os == 'Linux'
shell: bash
env:
INPUT_PACKAGE_CATEGORY: ${{ inputs.package-category }}
run: python3 "${GITHUB_WORKSPACE}/.github/scripts/install_dependencies_helper.py" fix-apt-alternatives

- name: Install optional apt packages (Linux)
Expand Down
149 changes: 149 additions & 0 deletions .github/scripts/android_instrumentation_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
#!/usr/bin/env python3
"""Install and run Android instrumentation (androidTest) tests for QGroundControl.

Invoked as a single line from the emulator-runner ``script:`` block, which
executes each script line as its own ``sh -c`` and therefore cannot host a
multi-line shell ``if``/``fi`` block. A no-op (exit 0) when ``--androidtest-apk``
is empty so the caller needs no shell conditional.
"""

from __future__ import annotations

import argparse
import re
import sys
from pathlib import Path

from ci_bootstrap import ensure_tools_dir

ensure_tools_dir(__file__)

from common.proc import run_bytes

# STATUS_CODE -1/-2 are per-test error/failure; session `INSTRUMENTATION_CODE: -1` is RESULT_OK — don't match it.
_FAILURE_PATTERN = re.compile(
r"^INSTRUMENTATION_STATUS_CODE: -[12]\b|FAILURES!!!|Process crashed",
re.MULTILINE,
)
_PASS_PATTERN = re.compile(r"^OK \([1-9][0-9]* test", re.MULTILINE)
# Also matches clean native System.exit (no Java exception), which `am instrument` reports as a crash.
_FATAL_LINE = re.compile(
r"FATAL EXCEPTION|SIGSEGV|SIGABRT|UnsatisfiedLinkError|abort message|"
r"VM exiting with result|System\.exit called|Crash of app"
)


def _decode(value: bytes) -> str:
return value.decode("utf-8", errors="replace")


def _capture_crash_logcat(package: str) -> str:
"""Drain logcat now — the emulator often goes offline before the diagnostics step runs."""
sections: list[str] = []

crash = _decode(run_bytes(["adb", "logcat", "-b", "crash", "-d", "-v", "time"]).stdout).strip()
if crash:
sections.append("===== crash buffer =====\n" + crash)

app_tag = package.rsplit(".", 1)[
-1
] # logcat truncates the process name to e.g. ".qgroundcontrol"
lines = _decode(run_bytes(["adb", "logcat", "-d", "-v", "time"]).stdout).splitlines()
relevant = [ln for ln in lines if app_tag in ln or _FATAL_LINE.search(ln)]
if relevant:
sections.append(
f"===== main buffer ({app_tag} + fatal) =====\n" + "\n".join(relevant[-200:])
)

return "\n\n".join(sections)


def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Run Android instrumentation tests for QGroundControl."
)
parser.add_argument(
"--androidtest-apk",
default="",
help="Path to the androidTest APK. Empty string is a no-op (exit 0).",
)
parser.add_argument(
"--package",
default="org.mavlink.qgroundcontrol",
help="Android application package id under test",
)
parser.add_argument(
"--runner",
default="org.mavlink.qgroundcontrol.QGCTestRunner",
help="Instrumentation test runner class",
)
parser.add_argument(
"--timeout",
type=int,
default=600,
help="Timeout in seconds for the am instrument run",
)
parser.add_argument(
"--log-output",
type=Path,
default=Path("/tmp/qgc_instrumentation.log"),
help="Path to store full am instrument output",
)
return parser.parse_args()


def main() -> int:
args = parse_args()

if not args.androidtest_apk:
print("::notice::No instrumentation APK provided; skipping androidTest run.")
return 0

print(f"Installing instrumentation APK: {args.androidtest_apk}")
install = run_bytes(["adb", "install", "-r", args.androidtest_apk])
if install.returncode != 0:
print(
f"::error::adb install of instrumentation APK failed: {_decode(install.stderr).strip()}"
)
return 1

run_bytes(["adb", "shell", "am", "force-stop", args.package])
run_bytes(["adb", "logcat", "-c"])

target = f"{args.package}.test/{args.runner}"
print(f"Running instrumentation: {target}")
result = run_bytes(
["adb", "shell", "am", "instrument", "-w", "-r", target],
timeout=args.timeout,
)
output = _decode(result.stdout) + _decode(result.stderr)
print(output)

failed = bool(_FAILURE_PATTERN.search(output))
passed = bool(_PASS_PATTERN.search(output))

if failed or not passed:
crash = _capture_crash_logcat(args.package)
if crash:
output += "\n\n===== logcat (crash excerpt) =====\n" + crash
print("::group::logcat crash excerpt")
print(crash)
print("::endgroup::")

args.log_output.write_text(output, encoding="utf-8")

if failed:
print("::error::Android instrumentation tests failed.")
return 1
if not passed:
print(
"::error::Android instrumentation tests did not report a passing run with at least one test."
)
return 1

print("Android instrumentation tests passed.")
return 0


if __name__ == "__main__":
sys.exit(main())
4 changes: 2 additions & 2 deletions .github/scripts/android_sdk_helper.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,8 +68,8 @@ def main() -> None:
sdkmanager = "sdkmanager"
gradlew = os.path.join(args.workspace, "android", "gradlew")

run_with_retries([sdkmanager, "--update"])
run_with_retries([gradlew, "--version"])
run_with_retries([sdkmanager, "--update"], timeout=600)
run_with_retries([gradlew, "--version"], timeout=300)


if __name__ == "__main__":
Expand Down
26 changes: 16 additions & 10 deletions .github/scripts/install_dependencies_helper.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@

from __future__ import annotations

import os
import re
import subprocess
import sys
Expand Down Expand Up @@ -101,6 +102,10 @@ def fix_apt_alternatives() -> None:
_sudo(["ldconfig"])

if not _ldconfig_has_blas():
# core-only installs (e.g. Android host) never pull BLAS; absence is expected, not fatal.
if os.environ.get("INPUT_PACKAGE_CATEGORY", "").strip():
print("::warning::libblas.so.3 absent; skipping (no BLAS in selected package category)")
return
print("::error::libblas.so.3 is still missing after repair attempt")
sys.exit(1)

Expand Down Expand Up @@ -132,16 +137,17 @@ def detect_python_version() -> None:

def print_packages() -> None:
"""Resolve the debian apt package list and emit it as a GITHUB_OUTPUT value."""
result = run_captured(
[
sys.executable,
"tools/setup/install_dependencies",
"--platform",
"debian",
"--print-packages",
],
check=True,
)
cmd = [
sys.executable,
"tools/setup/install_dependencies",
"--platform",
"debian",
"--print-packages",
]
category = os.environ.get("INPUT_PACKAGE_CATEGORY", "").strip()
if category:
cmd += ["--category", category]
result = run_captured(cmd, check=True)
write_github_output({"packages": result.stdout.strip()})


Expand Down
Loading
Loading