The open source OpenXR runtime
1# Copyright 2024, Collabora, Ltd.
2#
3# SPDX-License-Identifier: BSL-1.0
4#
5# Author: Rylie Pavlik <rylie.pavlik@collabora.com>
6"""
7pytest configuration and helpers for adb/Android.
8
9Helper functions to run tests using adb on an Android device.
10
11pytest serves as the test runner and collects the results.
12
13See README.md in this directory for requirements and running instructions.
14"""
15
16import subprocess
17import time
18from pathlib import Path
19from typing import Optional
20
21import pytest
22
23_NATIVE_ACTIVITY = "android.app.NativeActivity"
24helloxr_vulkan_pkg = "org.khronos.openxr.hello_xr.vulkan"
25helloxr_vulkan_activity = f"{helloxr_vulkan_pkg}/{_NATIVE_ACTIVITY}"
26helloxr_gles_pkg = "org.khronos.openxr.hello_xr.opengles"
27helloxr_gles_activity = f"{helloxr_gles_pkg}/{_NATIVE_ACTIVITY}"
28
29RUNTIME = "org.freedesktop.monado.openxr_runtime.out_of_process"
30
31_PKGS_TO_STOP = [
32 helloxr_gles_pkg,
33 helloxr_vulkan_pkg,
34 "org.khronos.openxr.cts",
35 "org.freedesktop.monado.openxr_runtime.in_process",
36 RUNTIME,
37]
38
39_REPO_ROOT = Path(__file__).resolve().parent.parent.parent
40
41
42class AdbTesting:
43 """pytest fixture for testing on an Android device with adb."""
44
45 def __init__(self, tmp_path: Optional[Path]):
46 """Initialize members."""
47 self.tmp_path = tmp_path
48 self.logcat_grabbed = False
49 self.logcat_echoed = False
50 self.log: Optional[str] = None
51
52 # TODO allow most of the following to be configured
53 self._ndk_ver = "26.3.11579264"
54 self._build_variant = "outOfProcessDebug"
55 self._sdk_root = Path.home() / "Android" / "Sdk"
56 self._arch = "arm64-v8a"
57 self._ndk_stack = self._sdk_root / "ndk" / self._ndk_ver / "ndk-stack"
58 self._caps_build_variant = (
59 self._build_variant[0].upper() + self._build_variant[1:]
60 )
61 self._sym_dir = (
62 _REPO_ROOT
63 / "src"
64 / "xrt"
65 / "targets"
66 / "openxr_android"
67 / "build"
68 / "intermediates"
69 / "merged_native_libs"
70 / self._build_variant
71 / f"merge{self._caps_build_variant}NativeLibs"
72 / "out"
73 / "lib"
74 / self._arch
75 )
76
77 def adb_call(self, cmd: list[str], **kwargs):
78 """Call ADB in a subprocess."""
79 return subprocess.check_call(cmd, **kwargs)
80
81 def adb_check_output(self, cmd: list[str], **kwargs):
82 """Call ADB in a subprocess and get the output."""
83 return subprocess.check_output(cmd, encoding="utf-8", **kwargs)
84
85 def get_device_state(self) -> Optional[str]:
86 try:
87 state = self.adb_check_output(["adb", "get-state"]).strip()
88 return state.strip()
89
90 except subprocess.CalledProcessError:
91 return None
92
93 def send_key(self, keyevent):
94 """Send a key event with `adb shell input keyevent`."""
95 time.sleep(1)
96 print(f"*** {keyevent}")
97 self.adb_call(["adb", "shell", "input", "keyevent", keyevent])
98
99 def send_tap(self, x, y):
100 """Send touchscreen tap at x, y."""
101 # sleep 1
102 print(f"*** tap {x} {y}")
103 self.adb_call(
104 [
105 "adb",
106 "shell",
107 "input",
108 "touchscreen",
109 "tap",
110 str(x),
111 str(y),
112 ]
113 )
114
115 def start_activity(self, activity):
116 """Start an activity and wait with `adb shell am start-activity -W`."""
117 time.sleep(1)
118 print(f"*** starting {activity} and waiting")
119 self.adb_call(["adb", "shell", "am", "start-activity", "-W", activity])
120
121 def stop_and_clear_all(self):
122 """
123 Stop relevant packages and clear their data.
124
125 This second step removes them from recents.
126 """
127 print("*** stopping all relevant packages and clearing their data")
128 for pkg in _PKGS_TO_STOP:
129 self.adb_call(["adb", "shell", "am", "force-stop", pkg])
130 self.adb_call(["adb", "shell", "pm", "clear", pkg])
131
132 def start_test(
133 self,
134 ):
135 """Ensure a clean starting setup."""
136 self.stop_and_clear_all()
137 self.adb_call(["adb", "logcat", "-c"])
138
139 @property
140 def logcat_fn(self):
141 """Get computed path of logcat file."""
142 assert self.tmp_path
143 return self.tmp_path / "logcat.txt"
144
145 def grab_logcat(self):
146 """Save logcat to the named file and return it as a string."""
147 log = self.adb_check_output(["adb", "logcat", "-d"])
148 log_fn = self.logcat_fn
149 with open(log_fn, "w", encoding="utf-8") as fp:
150 fp.write(log)
151 print(f"Logcat saved to {log_fn}")
152 self.logcat_grabbed = True
153 self.log = log
154 return log
155
156 def dump_logcat(self):
157 """Print logcat to stdout."""
158 if self.logcat_echoed:
159 return
160 if not self.logcat_grabbed:
161 self.grab_logcat()
162 print("*** Logcat:")
163 print(self.log)
164 self.logcat_echoed = True
165
166 def check_for_crash(self):
167 """Dump logcat, and look for a crash."""
168 __tracebackhide__ = True
169 log = self.grab_logcat()
170 log_fn = self.logcat_fn
171 crash_name = log_fn.with_name("crashes.txt")
172 if "backtrace:" in log:
173 subprocess.check_call(
174 " ".join(
175 (
176 str(x)
177 for x in (
178 self._ndk_stack,
179 "-sym",
180 self._sym_dir,
181 "-i",
182 log_fn,
183 ">",
184 crash_name,
185 )
186 )
187 ),
188 shell=True,
189 )
190 self.dump_logcat()
191
192 with open(crash_name, "r", encoding="utf-8") as fp:
193 print("*** Crash Backtrace(s):")
194 print(fp.read())
195 print(f"*** Crash backtrace in {crash_name}")
196 pytest.fail("Native crash backtrace detected in test")
197
198 if "FATAL EXCEPTION" in log:
199 self.dump_logcat()
200 pytest.fail("Java exception detected in test")
201
202 if "WindowManager: ANR" in log:
203 self.dump_logcat()
204 pytest.fail("ANR detected in test")
205
206 self.stop_and_clear_all()
207
208
209@pytest.fixture(scope="function")
210def adb(tmp_path):
211 """Provide AdbTesting instance and cleanup as pytest fixture."""
212 adb_testing = AdbTesting(tmp_path)
213
214 adb_testing.start_test()
215
216 # Body of test happens here
217 yield adb_testing
218
219 # Grab logcat in case it wasn't grabbed, and dump to stdout
220 adb_testing.dump_logcat()
221
222 # Cleanup
223 adb_testing.stop_and_clear_all()
224
225
226@pytest.fixture(scope="session", autouse=True)
227def log_device_and_build(record_testsuite_property):
228 """Session fixture, autoused, to record device data."""
229 adb = AdbTesting(None)
230
231 try:
232 device = adb.adb_check_output(
233 ["adb", "shell", "getprop", "ro.product.vendor.device"]
234 )
235 build = adb.adb_check_output(
236 ["adb", "shell", "getprop", "ro.product.build.fingerprint"]
237 )
238 except subprocess.CalledProcessError:
239 pytest.skip("adb shell getprop failed - no device connected?")
240
241 record_testsuite_property("device", device.strip())
242 record_testsuite_property("build", build.strip())
243
244
245def device_is_available():
246 """Return True if an ADB device is available."""
247 adb = AdbTesting(None)
248
249 try:
250 state = adb.adb_check_output(["adb", "get-state"]).strip()
251 return state == "device"
252
253 except subprocess.CalledProcessError:
254 return False
255
256
257def pytest_addoption(parser: pytest.Parser):
258 """Pytest addoption function."""
259 parser.addoption(
260 "--adb",
261 action="store_true",
262 default=False,
263 help="run ADB/Android device tests",
264 )
265
266
267def pytest_configure(config):
268 """Configure function for pytest."""
269 config.addinivalue_line(
270 "markers",
271 "adb: mark test as needing adb and thus only run when an "
272 "Android device is available",
273 )