#!/usr/bin/env python # # This Source Code Form is subject to the terms of the Mozilla Public # License, v. 2.0. If a copy of the MPL was not distributed with this # file, You can obtain one at http://mozilla.org/MPL/2.0/. from __future__ import with_statement from optparse import OptionParser import datetime import glob import os import posixpath import shutil import sys import tempfile import time import traceback import mozcrash import mozdevice import mozinfo import mozlog LOGGER_NAME = 'gtest' log = mozlog.unstructured.getLogger(LOGGER_NAME) class RemoteGTests(object): """ A test harness to run gtest on Android. """ def __init__(self): self.device = None def build_environment(self, shuffle, test_filter): """ Create and return a dictionary of all the appropriate env variables and values. """ env = {} env["XPCOM_DEBUG_BREAK"] = "stack-and-abort" env["MOZ_CRASHREPORTER_NO_REPORT"] = "1" env["MOZ_CRASHREPORTER"] = "1" env["MOZ_RUN_GTEST"] = "1" # custom output parser is mandatory on Android env["MOZ_TBPL_PARSER"] = "1" env["MOZ_GTEST_LOG_PATH"] = self.remote_log env["MOZ_GTEST_CWD"] = self.remote_profile env["MOZ_GTEST_MINIDUMPS_PATH"] = self.remote_minidumps env["MOZ_IN_AUTOMATION"] = "1" if shuffle: env["GTEST_SHUFFLE"] = "True" if test_filter: env["GTEST_FILTER"] = test_filter return env def run_gtest(self, test_dir, shuffle, test_filter, package, adb_path, device_serial, remote_test_root, libxul_path, symbols_path): """ Launch the test app, run gtest, collect test results and wait for completion. Return False if a crash or other failure is detected, else True. """ update_mozinfo() self.device = mozdevice.ADBDevice(adb=adb_path, device=device_serial, test_root=remote_test_root, logger_name=LOGGER_NAME, verbose=True) root = self.device.test_root self.remote_profile = posixpath.join(root, 'gtest-profile') self.remote_minidumps = posixpath.join(root, 'gtest-minidumps') self.remote_log = posixpath.join(root, 'gtest.log') self.package = package self.cleanup() self.device.mkdir(self.remote_profile, parents=True) self.device.mkdir(self.remote_minidumps, parents=True) log.info("Running Android gtest") if not self.device.is_app_installed(self.package): raise Exception("%s is not installed on this device" % self.package) if not self.device._have_root_shell: raise Exception("a device with a root shell is required to run Android gtest") # TODO -- consider packaging the gtest libxul.so in an apk remote = "/data/app/%s-1/lib/x86_64/" % self.package self.device.push(libxul_path, remote) # Push support files to device. Avoid sub-directories so that libxul.so # is not included. for f in glob.glob(os.path.join(test_dir, "*")): if not os.path.isdir(f): self.device.push(f, self.remote_profile) env = self.build_environment(shuffle, test_filter) args = ["-unittest", "--gtest_death_test_style=threadsafe", "-profile %s" % self.remote_profile] if 'geckoview' in self.package: activity = "TestRunnerActivity" self.device.launch_activity(self.package, activity_name=activity, e10s=False, # gtest is non-e10s on desktop moz_env=env, extra_args=args) else: self.device.launch_fennec(self.package, moz_env=env, extra_args=args) waiter = AppWaiter(self.device, self.remote_log) timed_out = waiter.wait(self.package) self.shutdown(use_kill=True if timed_out else False) if self.check_for_crashes(symbols_path): return False return True def shutdown(self, use_kill): """ Stop the remote application. If use_kill is specified, a multi-stage kill procedure is used, attempting to trigger ANR and minidump reports before ending the process. """ if not use_kill: self.device.stop_application(self.package) else: # Trigger an ANR report with "kill -3" (SIGQUIT) try: self.device.pkill(self.package, sig=3, attempts=1, root=True) except mozdevice.ADBTimeoutError: raise except: # NOQA: E722 pass time.sleep(3) # Trigger a breakpad dump with "kill -6" (SIGABRT) try: self.device.pkill(self.package, sig=6, attempts=1, root=True) except mozdevice.ADBTimeoutError: raise except: # NOQA: E722 pass # Wait for process to end retries = 0 while retries < 3: if self.device.process_exist(self.package): log.info("%s still alive after SIGABRT: waiting..." % self.package) time.sleep(5) else: break retries += 1 if self.device.process_exist(self.package): try: self.device.pkill(self.package, sig=9, attempts=1, root=True) except mozdevice.ADBTimeoutError: raise except: # NOQA: E722 log.warning("%s still alive after SIGKILL!" % self.package) if self.device.process_exist(self.package): self.device.stop_application(self.package) # Test harnesses use the MOZ_CRASHREPORTER environment variables to suppress # the interactive crash reporter, but that may not always be effective; # check for and cleanup errant crashreporters. crashreporter = "%s.CrashReporter" % self.package if self.device.process_exist(crashreporter): log.warning("%s unexpectedly found running. Killing..." % crashreporter) try: self.device.pkill(crashreporter, root=True) except mozdevice.ADBTimeoutError: raise except: # NOQA: E722 pass if self.device.process_exist(crashreporter): log.error("%s still running!!" % crashreporter) def check_for_crashes(self, symbols_path): """ Pull minidumps from the remote device and generate crash reports. Returns True if a crash was detected, or suspected. """ try: dump_dir = tempfile.mkdtemp() remote_dir = self.remote_minidumps if not self.device.is_dir(remote_dir): log.warning("No crash directory (%s) found on remote device" % remote_dir) return True self.device.pull(remote_dir, dump_dir) crashed = mozcrash.check_for_crashes(dump_dir, symbols_path, test_name="gtest") except Exception as e: log.error("unable to check for crashes: %s" % str(e)) crashed = True finally: try: shutil.rmtree(dump_dir) except Exception: log.warning("unable to remove directory: %s" % dump_dir) return crashed def cleanup(self): if self.device: self.device.stop_application(self.package) self.device.rm(self.remote_log, force=True, root=True) self.device.rm(self.remote_profile, recursive=True, force=True, root=True) self.device.rm(self.remote_minidumps, recursive=True, force=True, root=True) class AppWaiter(object): def __init__(self, device, remote_log, test_proc_timeout=1200, test_proc_no_output_timeout=300, test_proc_start_timeout=60, output_poll_interval=10): self.device = device self.remote_log = remote_log self.start_time = datetime.datetime.now() self.timeout_delta = datetime.timedelta(seconds=test_proc_timeout) self.output_timeout_delta = datetime.timedelta(seconds=test_proc_no_output_timeout) self.start_timeout_delta = datetime.timedelta(seconds=test_proc_start_timeout) self.output_poll_interval = output_poll_interval self.last_output_time = datetime.datetime.now() self.remote_log_len = 0 def start_timed_out(self): if datetime.datetime.now() - self.start_time > self.start_timeout_delta: return True return False def timed_out(self): if datetime.datetime.now() - self.start_time > self.timeout_delta: return True return False def output_timed_out(self): if datetime.datetime.now() - self.last_output_time > self.output_timeout_delta: return True return False def get_top(self): top = self.device.get_top_activity(timeout=60) if top is None: log.info("Failed to get top activity, retrying, once...") top = self.device.get_top_activity(timeout=60) return top def wait_for_start(self, package): top = None while top != package and not self.start_timed_out(): if self.update_log(): # if log content is available, assume the app started; otherwise, # a short run (few tests) might complete without ever being detected # in the foreground return package time.sleep(1) top = self.get_top() return top def wait(self, package): """ Wait until: - the app loses foreground, or - no new output is observed for the output timeout, or - the timeout is exceeded. While waiting, update the log every periodically: pull the gtest log from device and log any new content. """ top = self.wait_for_start(package) if top != package: log.testFail("gtest | %s failed to start" % package) return while not self.timed_out(): if not self.update_log(): top = self.get_top() if top != package or self.output_timed_out(): break time.sleep(self.output_poll_interval) self.update_log() if self.timed_out(): log.testFail("gtest | timed out after %d seconds", self.timeout_delta.seconds) elif self.output_timed_out(): log.testFail("gtest | timed out after %d seconds without output", self.output_timeout_delta.seconds) else: log.info("gtest | wait for %s complete; top activity=%s" % (package, top)) return True if top == package else False def update_log(self): """ Pull the test log from the remote device and display new content. """ if not self.device.is_file(self.remote_log): return False try: new_content = self.device.get_file(self.remote_log, offset=self.remote_log_len) except mozdevice.ADBTimeoutError: raise except Exception as e: log.info("exception reading log: %s" % str(e)) return False if not new_content: return False last_full_line_pos = new_content.rfind('\n') if last_full_line_pos <= 0: # wait for a full line return False # trim partial line new_content = new_content[:last_full_line_pos] self.remote_log_len += len(new_content) for line in new_content.lstrip('\n').split('\n'): print(line) self.last_output_time = datetime.datetime.now() return True class remoteGtestOptions(OptionParser): def __init__(self): OptionParser.__init__(self, usage="usage: %prog [options] test_filter") self.add_option("--package", dest="package", default="org.mozilla.geckoview.test", help="Package name of test app.") self.add_option("--adbpath", action="store", type=str, dest="adb_path", default="adb", help="Path to adb binary.") self.add_option("--deviceSerial", action="store", type=str, dest="device_serial", help="adb serial number of remote device. This is required " "when more than one device is connected to the host. " "Use 'adb devices' to see connected devices. ") self.add_option("--remoteTestRoot", action="store", type=str, dest="remote_test_root", help="Remote directory to use as test root " "(eg. /mnt/sdcard/tests or /data/local/tests).") self.add_option("--libxul", action="store", type=str, dest="libxul_path", default=None, help="Path to gtest libxul.so.") self.add_option("--symbols-path", dest="symbols_path", default=None, help="absolute path to directory containing breakpad " "symbols, or the URL of a zip file containing symbols") self.add_option("--shuffle", action="store_true", default=False, help="Randomize the execution order of tests.") self.add_option("--tests-path", default=None, help="Path to gtest directory containing test support files.") def update_mozinfo(): """ Walk up directories to find mozinfo.json and update the info. """ path = os.path.abspath(os.path.realpath(os.path.dirname(__file__))) dirs = set() while path != os.path.expanduser('~'): if path in dirs: break dirs.add(path) path = os.path.split(path)[0] mozinfo.find_and_update_from_json(*dirs) def main(): parser = remoteGtestOptions() options, args = parser.parse_args() if not options.libxul_path: parser.error("--libxul is required") sys.exit(1) if len(args) > 1: parser.error("only one test_filter is allowed") sys.exit(1) test_filter = args[0] if args else None tester = RemoteGTests() result = False try: device_exception = False result = tester.run_gtest(options.tests_path, options.shuffle, test_filter, options.package, options.adb_path, options.device_serial, options.remote_test_root, options.libxul_path, options.symbols_path) except KeyboardInterrupt: log.info("gtest | Received keyboard interrupt") except Exception as e: log.error(str(e)) traceback.print_exc() if isinstance(e, mozdevice.ADBTimeoutError): device_exception = True finally: if not device_exception: tester.cleanup() sys.exit(0 if result else 1) if __name__ == '__main__': main()