···11+# Android device tests
22+33+<!--
44+Copyright 2024, Collabora, Ltd.
55+66+SPDX-License-Identifier: CC-BY-4.0
77+-->
88+99+This directory contains some tests to run on an Android device over ADB,
1010+primarily to verify some lifecycle handling right now.
1111+1212+It uses [pytest][] as the test runner, which is more readable, maintainable, and
1313+usable than the earlier bash scripts, and gives the option for e.g. junit-style
1414+output for CI usage.
1515+1616+The actual tests are in the `test_*.py` files, while `conftest.py` configures
1717+the pytest framework, as well as provides constants and fixtures for use in the
1818+tests themselves.
1919+2020+[pytest]: https://pytest.org
2121+2222+## Preconditions
2323+2424+- Have `adb` in the path, and only the device under test connected. (or, have
2525+ environment variables set appropriately so that
2626+ `adb shell getprop ro.product.vendor.device` shows you the expected device.)
2727+- Have Python and pytest installed, either system-wide or in a `venv`. The
2828+ version of pytest in Debian Bookworm is suitable.
2929+- Have built and installed the "outOfProcessDebug" build of Monado.
3030+ (`./gradlew installOOPD` in root dir)
3131+- Have set the out-of-process runtime as active in the OpenXR Runtime Broker (if
3232+ applicable).
3333+- Have installed both Vulkan and OpenGL ES versions of the `hello_xr` sample app
3434+ from Khronos. (Binaries from a release are fine.)
3535+- If you want the tests to pass, turn on "Draw over other apps" -- but this is
3636+ actually a bug to require this.
3737+3838+## Instructions
3939+4040+Change your current working directory to this one. (Otherwise the extra
4141+argument you need to enable the ADB tests will not be recognized by pytest.)
4242+4343+```sh
4444+cd tests/android/
4545+```
4646+4747+Run the following command, or one based on it:
4848+4949+```sh
5050+pytest -v --adb
5151+```
5252+5353+Or, if you want junit-style output, add a variation on these two args:
5454+`--junit-xml=android-tests.xml -o junit_family=xunit1` for an overall command
5555+like:
5656+5757+```sh
5858+pytest -v --adb --junit-xml=android-tests.xml -o junit_family=xunit1
5959+```
6060+6161+Another useful options is `-k` which can be followed by a pattern used to
6262+selecet a subset of tests to run. Handy if you only want to run one or two
6363+tests.
+273
tests/android/conftest.py
···11+# Copyright 2024, Collabora, Ltd.
22+#
33+# SPDX-License-Identifier: BSL-1.0
44+#
55+# Author: Rylie Pavlik <rylie.pavlik@collabora.com>
66+"""
77+pytest configuration and helpers for adb/Android.
88+99+Helper functions to run tests using adb on an Android device.
1010+1111+pytest serves as the test runner and collects the results.
1212+1313+See README.md in this directory for requirements and running instructions.
1414+"""
1515+1616+import subprocess
1717+import time
1818+from pathlib import Path
1919+from typing import Optional
2020+2121+import pytest
2222+2323+_NATIVE_ACTIVITY = "android.app.NativeActivity"
2424+helloxr_vulkan_pkg = "org.khronos.openxr.hello_xr.vulkan"
2525+helloxr_vulkan_activity = f"{helloxr_vulkan_pkg}/{_NATIVE_ACTIVITY}"
2626+helloxr_gles_pkg = "org.khronos.openxr.hello_xr.opengles"
2727+helloxr_gles_activity = f"{helloxr_gles_pkg}/{_NATIVE_ACTIVITY}"
2828+2929+RUNTIME = "org.freedesktop.monado.openxr_runtime.out_of_process"
3030+3131+_PKGS_TO_STOP = [
3232+ helloxr_gles_pkg,
3333+ helloxr_vulkan_pkg,
3434+ "org.khronos.openxr.cts",
3535+ "org.freedesktop.monado.openxr_runtime.in_process",
3636+ RUNTIME,
3737+]
3838+3939+_REPO_ROOT = Path(__file__).resolve().parent.parent.parent
4040+4141+4242+class AdbTesting:
4343+ """pytest fixture for testing on an Android device with adb."""
4444+4545+ def __init__(self, tmp_path: Optional[Path]):
4646+ """Initialize members."""
4747+ self.tmp_path = tmp_path
4848+ self.logcat_grabbed = False
4949+ self.logcat_echoed = False
5050+ self.log: Optional[str] = None
5151+5252+ # TODO allow most of the following to be configured
5353+ self._ndk_ver = "26.3.11579264"
5454+ self._build_variant = "outOfProcessDebug"
5555+ self._sdk_root = Path.home() / "Android" / "Sdk"
5656+ self._arch = "arm64-v8a"
5757+ self._ndk_stack = self._sdk_root / "ndk" / self._ndk_ver / "ndk-stack"
5858+ self._caps_build_variant = (
5959+ self._build_variant[0].upper() + self._build_variant[1:]
6060+ )
6161+ self._sym_dir = (
6262+ _REPO_ROOT
6363+ / "src"
6464+ / "xrt"
6565+ / "targets"
6666+ / "openxr_android"
6767+ / "build"
6868+ / "intermediates"
6969+ / "merged_native_libs"
7070+ / self._build_variant
7171+ / f"merge{self._caps_build_variant}NativeLibs"
7272+ / "out"
7373+ / "lib"
7474+ / self._arch
7575+ )
7676+7777+ def adb_call(self, cmd: list[str], **kwargs):
7878+ """Call ADB in a subprocess."""
7979+ return subprocess.check_call(cmd, **kwargs)
8080+8181+ def adb_check_output(self, cmd: list[str], **kwargs):
8282+ """Call ADB in a subprocess and get the output."""
8383+ return subprocess.check_output(cmd, encoding="utf-8", **kwargs)
8484+8585+ def get_device_state(self) -> Optional[str]:
8686+ try:
8787+ state = self.adb_check_output(["adb", "get-state"]).strip()
8888+ return state.strip()
8989+9090+ except subprocess.CalledProcessError:
9191+ return None
9292+9393+ def send_key(self, keyevent):
9494+ """Send a key event with `adb shell input keyevent`."""
9595+ time.sleep(1)
9696+ print(f"*** {keyevent}")
9797+ self.adb_call(["adb", "shell", "input", "keyevent", keyevent])
9898+9999+ def send_tap(self, x, y):
100100+ """Send touchscreen tap at x, y."""
101101+ # sleep 1
102102+ print(f"*** tap {x} {y}")
103103+ self.adb_call(
104104+ [
105105+ "adb",
106106+ "shell",
107107+ "input",
108108+ "touchscreen",
109109+ "tap",
110110+ str(x),
111111+ str(y),
112112+ ]
113113+ )
114114+115115+ def start_activity(self, activity):
116116+ """Start an activity and wait with `adb shell am start-activity -W`."""
117117+ time.sleep(1)
118118+ print(f"*** starting {activity} and waiting")
119119+ self.adb_call(["adb", "shell", "am", "start-activity", "-W", activity])
120120+121121+ def stop_and_clear_all(self):
122122+ """
123123+ Stop relevant packages and clear their data.
124124+125125+ This second step removes them from recents.
126126+ """
127127+ print("*** stopping all relevant packages and clearing their data")
128128+ for pkg in _PKGS_TO_STOP:
129129+ self.adb_call(["adb", "shell", "am", "force-stop", pkg])
130130+ self.adb_call(["adb", "shell", "pm", "clear", pkg])
131131+132132+ def start_test(
133133+ self,
134134+ ):
135135+ """Ensure a clean starting setup."""
136136+ self.stop_and_clear_all()
137137+ self.adb_call(["adb", "logcat", "-c"])
138138+139139+ @property
140140+ def logcat_fn(self):
141141+ """Get computed path of logcat file."""
142142+ assert self.tmp_path
143143+ return self.tmp_path / "logcat.txt"
144144+145145+ def grab_logcat(self):
146146+ """Save logcat to the named file and return it as a string."""
147147+ log = self.adb_check_output(["adb", "logcat", "-d"])
148148+ log_fn = self.logcat_fn
149149+ with open(log_fn, "w", encoding="utf-8") as fp:
150150+ fp.write(log)
151151+ print(f"Logcat saved to {log_fn}")
152152+ self.logcat_grabbed = True
153153+ self.log = log
154154+ return log
155155+156156+ def dump_logcat(self):
157157+ """Print logcat to stdout."""
158158+ if self.logcat_echoed:
159159+ return
160160+ if not self.logcat_grabbed:
161161+ self.grab_logcat()
162162+ print("*** Logcat:")
163163+ print(self.log)
164164+ self.logcat_echoed = True
165165+166166+ def check_for_crash(self):
167167+ """Dump logcat, and look for a crash."""
168168+ __tracebackhide__ = True
169169+ log = self.grab_logcat()
170170+ log_fn = self.logcat_fn
171171+ crash_name = log_fn.with_name("crashes.txt")
172172+ if "backtrace:" in log:
173173+ subprocess.check_call(
174174+ " ".join(
175175+ (
176176+ str(x)
177177+ for x in (
178178+ self._ndk_stack,
179179+ "-sym",
180180+ self._sym_dir,
181181+ "-i",
182182+ log_fn,
183183+ ">",
184184+ crash_name,
185185+ )
186186+ )
187187+ ),
188188+ shell=True,
189189+ )
190190+ self.dump_logcat()
191191+192192+ with open(crash_name, "r", encoding="utf-8") as fp:
193193+ print("*** Crash Backtrace(s):")
194194+ print(fp.read())
195195+ print(f"*** Crash backtrace in {crash_name}")
196196+ pytest.fail("Native crash backtrace detected in test")
197197+198198+ if "FATAL EXCEPTION" in log:
199199+ self.dump_logcat()
200200+ pytest.fail("Java exception detected in test")
201201+202202+ if "WindowManager: ANR" in log:
203203+ self.dump_logcat()
204204+ pytest.fail("ANR detected in test")
205205+206206+ self.stop_and_clear_all()
207207+208208+209209+@pytest.fixture(scope="function")
210210+def adb(tmp_path):
211211+ """Provide AdbTesting instance and cleanup as pytest fixture."""
212212+ adb_testing = AdbTesting(tmp_path)
213213+214214+ adb_testing.start_test()
215215+216216+ # Body of test happens here
217217+ yield adb_testing
218218+219219+ # Grab logcat in case it wasn't grabbed, and dump to stdout
220220+ adb_testing.dump_logcat()
221221+222222+ # Cleanup
223223+ adb_testing.stop_and_clear_all()
224224+225225+226226+@pytest.fixture(scope="session", autouse=True)
227227+def log_device_and_build(record_testsuite_property):
228228+ """Session fixture, autoused, to record device data."""
229229+ adb = AdbTesting(None)
230230+231231+ try:
232232+ device = adb.adb_check_output(
233233+ ["adb", "shell", "getprop", "ro.product.vendor.device"]
234234+ )
235235+ build = adb.adb_check_output(
236236+ ["adb", "shell", "getprop", "ro.product.build.fingerprint"]
237237+ )
238238+ except subprocess.CalledProcessError:
239239+ pytest.skip("adb shell getprop failed - no device connected?")
240240+241241+ record_testsuite_property("device", device.strip())
242242+ record_testsuite_property("build", build.strip())
243243+244244+245245+def device_is_available():
246246+ """Return True if an ADB device is available."""
247247+ adb = AdbTesting(None)
248248+249249+ try:
250250+ state = adb.adb_check_output(["adb", "get-state"]).strip()
251251+ return state == "device"
252252+253253+ except subprocess.CalledProcessError:
254254+ return False
255255+256256+257257+def pytest_addoption(parser: pytest.Parser):
258258+ """Pytest addoption function."""
259259+ parser.addoption(
260260+ "--adb",
261261+ action="store_true",
262262+ default=False,
263263+ help="run ADB/Android device tests",
264264+ )
265265+266266+267267+def pytest_configure(config):
268268+ """Configure function for pytest."""
269269+ config.addinivalue_line(
270270+ "markers",
271271+ "adb: mark test as needing adb and thus only run when an "
272272+ "Android device is available",
273273+ )