diff options
Diffstat (limited to 'testing/mozharness/mozharness/mozilla/testing/android.py')
-rw-r--r-- | testing/mozharness/mozharness/mozilla/testing/android.py | 724 |
1 files changed, 724 insertions, 0 deletions
diff --git a/testing/mozharness/mozharness/mozilla/testing/android.py b/testing/mozharness/mozharness/mozilla/testing/android.py new file mode 100644 index 0000000000..7fc2974b32 --- /dev/null +++ b/testing/mozharness/mozharness/mozilla/testing/android.py @@ -0,0 +1,724 @@ +#!/usr/bin/env python +# ***** BEGIN LICENSE BLOCK ***** +# 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/. +# ***** END LICENSE BLOCK ***** + +import datetime +import functools +import glob +import os +import posixpath +import re +import signal +import subprocess +import tempfile +import time +from threading import Timer + +import six + +from mozharness.base.script import PostScriptAction, PreScriptAction +from mozharness.mozilla.automation import EXIT_STATUS_DICT, TBPL_RETRY + + +def ensure_dir(dir): + """Ensures the given directory exists""" + if dir and not os.path.exists(dir): + try: + os.makedirs(dir) + except OSError as error: + if error.errno != errno.EEXIST: + raise + + +class AndroidMixin(object): + """ + Mixin class used by Android test scripts. + """ + + def __init__(self, **kwargs): + self._adb_path = None + self._device = None + self.app_name = None + self.device_name = os.environ.get("DEVICE_NAME", None) + self.device_serial = os.environ.get("DEVICE_SERIAL", None) + self.device_ip = os.environ.get("DEVICE_IP", None) + self.logcat_proc = None + self.logcat_file = None + self.use_gles3 = False + self.use_root = True + self.xre_path = None + super(AndroidMixin, self).__init__(**kwargs) + + @property + def adb_path(self): + """Get the path to the adb executable.""" + self.activate_virtualenv() + if not self._adb_path: + self._adb_path = self.query_exe("adb") + return self._adb_path + + @property + def device(self): + if not self._device: + # We must access the adb_path property to activate the + # virtualenv before importing mozdevice in order to + # import the mozdevice installed into the virtualenv and + # not any system-wide installation of mozdevice. + adb = self.adb_path + import mozdevice + + self._device = mozdevice.ADBDeviceFactory( + adb=adb, device=self.device_serial, use_root=self.use_root + ) + return self._device + + @property + def is_android(self): + c = self.config + installer_url = c.get("installer_url", None) + return ( + self.device_serial is not None + or self.is_emulator + or ( + installer_url is not None + and (installer_url.endswith(".apk") or installer_url.endswith(".aab")) + ) + ) + + @property + def is_emulator(self): + c = self.config + return True if c.get("emulator_avd_name") else False + + def _get_repo_url(self, path): + """ + Return a url for a file (typically a tooltool manifest) in this hg repo + and using this revision (or mozilla-central/default if repo/rev cannot + be determined). + + :param path specifies the directory path to the file of interest. + """ + if "GECKO_HEAD_REPOSITORY" in os.environ and "GECKO_HEAD_REV" in os.environ: + # probably taskcluster + repo = os.environ["GECKO_HEAD_REPOSITORY"] + revision = os.environ["GECKO_HEAD_REV"] + else: + # something unexpected! + repo = "https://hg.mozilla.org/mozilla-central" + revision = "default" + self.warning( + "Unable to find repo/revision for manifest; " + "using mozilla-central/default" + ) + url = "%s/raw-file/%s/%s" % (repo, revision, path) + return url + + def _tooltool_fetch(self, url, dir): + c = self.config + manifest_path = self.download_file( + url, file_name="releng.manifest", parent_dir=dir + ) + if not os.path.exists(manifest_path): + self.fatal( + "Could not retrieve manifest needed to retrieve " + "artifacts from %s" % manifest_path + ) + # from TooltoolMixin, included in TestingMixin + self.tooltool_fetch( + manifest_path, output_dir=dir, cache=c.get("tooltool_cache", None) + ) + + def _launch_emulator(self): + env = self.query_env() + + # Write a default ddms.cfg to avoid unwanted prompts + avd_home_dir = self.abs_dirs["abs_avds_dir"] + DDMS_FILE = os.path.join(avd_home_dir, "ddms.cfg") + with open(DDMS_FILE, "w") as f: + f.write("pingOptIn=false\npingId=0\n") + self.info("wrote dummy %s" % DDMS_FILE) + + # Delete emulator auth file, so it doesn't prompt + AUTH_FILE = os.path.join( + os.path.expanduser("~"), ".emulator_console_auth_token" + ) + if os.path.exists(AUTH_FILE): + try: + os.remove(AUTH_FILE) + self.info("deleted %s" % AUTH_FILE) + except Exception: + self.warning("failed to remove %s" % AUTH_FILE) + + env["ANDROID_EMULATOR_HOME"] = avd_home_dir + avd_path = os.path.join(avd_home_dir, "avd") + if os.path.exists(avd_path): + env["ANDROID_AVD_HOME"] = avd_path + self.info("Found avds at %s" % avd_path) + else: + self.warning("AVDs missing? Not found at %s" % avd_path) + + if "deprecated_sdk_path" in self.config: + sdk_path = os.path.abspath(os.path.join(avd_home_dir, "..")) + else: + sdk_path = self.abs_dirs["abs_sdk_dir"] + if os.path.exists(sdk_path): + env["ANDROID_SDK_HOME"] = sdk_path + env["ANDROID_SDK_ROOT"] = sdk_path + self.info("Found sdk at %s" % sdk_path) + else: + self.warning("Android sdk missing? Not found at %s" % sdk_path) + + avd_config_path = os.path.join( + avd_path, "%s.ini" % self.config["emulator_avd_name"] + ) + avd_folder = os.path.join(avd_path, "%s.avd" % self.config["emulator_avd_name"]) + if os.path.isfile(avd_config_path): + # The ini file points to the absolute path to the emulator folder, + # which might be different, so we need to update it. + old_config = "" + with open(avd_config_path, "r") as config_file: + old_config = config_file.readlines() + self.info("Old Config: %s" % old_config) + with open(avd_config_path, "w") as config_file: + for line in old_config: + if line.startswith("path="): + config_file.write("path=%s\n" % avd_folder) + self.info("Updating path from: %s" % line) + else: + config_file.write("%s\n" % line) + else: + self.warning("Could not find config path at %s" % avd_config_path) + + # enable EGL 3.0 in advancedFeatures.ini + AF_FILE = os.path.join(avd_home_dir, "advancedFeatures.ini") + with open(AF_FILE, "w") as f: + if self.use_gles3: + f.write("GLESDynamicVersion=on\n") + else: + f.write("GLESDynamicVersion=off\n") + + # extra diagnostics for kvm acceleration + emu = self.config.get("emulator_process_name") + if os.path.exists("/dev/kvm") and emu and "x86" in emu: + try: + self.run_command(["ls", "-l", "/dev/kvm"]) + self.run_command(["kvm-ok"]) + self.run_command(["emulator", "-accel-check"], env=env) + except Exception as e: + self.warning("Extra kvm diagnostics failed: %s" % str(e)) + + self.info("emulator env: %s" % str(env)) + command = ["emulator", "-avd", self.config["emulator_avd_name"]] + if "emulator_extra_args" in self.config: + command += self.config["emulator_extra_args"] + + dir = self.query_abs_dirs()["abs_blob_upload_dir"] + tmp_file = tempfile.NamedTemporaryFile( + mode="w", prefix="emulator-", suffix=".log", dir=dir, delete=False + ) + self.info("Launching the emulator with: %s" % " ".join(command)) + self.info("Writing log to %s" % tmp_file.name) + proc = subprocess.Popen( + command, stdout=tmp_file, stderr=tmp_file, env=env, bufsize=0 + ) + return proc + + def _verify_emulator(self): + boot_ok = self._retry( + 30, + 10, + self.is_boot_completed, + "Verify Android boot completed", + max_time=330, + ) + if not boot_ok: + self.warning("Unable to verify Android boot completion") + return False + return True + + def _verify_emulator_and_restart_on_fail(self): + emulator_ok = self._verify_emulator() + if not emulator_ok: + self.device_screenshot("screenshot-emulator-start") + self.kill_processes(self.config["emulator_process_name"]) + subprocess.check_call(["ps", "-ef"]) + # remove emulator tmp files + for dir in glob.glob("/tmp/android-*"): + self.rmtree(dir) + time.sleep(5) + self.emulator_proc = self._launch_emulator() + return emulator_ok + + def _retry(self, max_attempts, interval, func, description, max_time=0): + """ + Execute func until it returns True, up to max_attempts times, waiting for + interval seconds between each attempt. description is logged on each attempt. + If max_time is specified, no further attempts will be made once max_time + seconds have elapsed; this provides some protection for the case where + the run-time for func is long or highly variable. + """ + status = False + attempts = 0 + if max_time > 0: + end_time = datetime.datetime.now() + datetime.timedelta(seconds=max_time) + else: + end_time = None + while attempts < max_attempts and not status: + if (end_time is not None) and (datetime.datetime.now() > end_time): + self.info( + "Maximum retry run-time of %d seconds exceeded; " + "remaining attempts abandoned" % max_time + ) + break + if attempts != 0: + self.info("Sleeping %d seconds" % interval) + time.sleep(interval) + attempts += 1 + self.info( + ">> %s: Attempt #%d of %d" % (description, attempts, max_attempts) + ) + status = func() + return status + + def dump_perf_info(self): + """ + Dump some host and android device performance-related information + to an artifact file, to help understand task performance. + """ + dir = self.query_abs_dirs()["abs_blob_upload_dir"] + perf_path = os.path.join(dir, "android-performance.log") + with open(perf_path, "w") as f: + f.write("\n\nHost cpufreq/scaling_governor:\n") + cpus = glob.glob("/sys/devices/system/cpu/cpu*/cpufreq/scaling_governor") + for cpu in cpus: + out = subprocess.check_output(["cat", cpu], universal_newlines=True) + f.write("%s: %s" % (cpu, out)) + + f.write("\n\nHost /proc/cpuinfo:\n") + out = subprocess.check_output( + ["cat", "/proc/cpuinfo"], universal_newlines=True + ) + f.write(out) + + f.write("\n\nHost /proc/meminfo:\n") + out = subprocess.check_output( + ["cat", "/proc/meminfo"], universal_newlines=True + ) + f.write(out) + + f.write("\n\nHost process list:\n") + out = subprocess.check_output(["ps", "-ef"], universal_newlines=True) + f.write(out) + + f.write("\n\nDevice /proc/cpuinfo:\n") + cmd = "cat /proc/cpuinfo" + out = self.shell_output(cmd) + f.write(out) + cpuinfo = out + + f.write("\n\nDevice /proc/meminfo:\n") + cmd = "cat /proc/meminfo" + out = self.shell_output(cmd) + f.write(out) + + f.write("\n\nDevice process list:\n") + cmd = "ps" + out = self.shell_output(cmd) + f.write(out) + + # Search android cpuinfo for "BogoMIPS"; if found and < (minimum), retry + # this task, in hopes of getting a higher-powered environment. + # (Carry on silently if BogoMIPS is not found -- this may vary by + # Android implementation -- no big deal.) + # See bug 1321605: Sometimes the emulator is really slow, and + # low bogomips can be a good predictor of that condition. + bogomips_minimum = int(self.config.get("bogomips_minimum") or 0) + for line in cpuinfo.split("\n"): + m = re.match(r"BogoMIPS.*: (\d*)", line, re.IGNORECASE) + if m: + bogomips = int(m.group(1)) + if bogomips_minimum > 0 and bogomips < bogomips_minimum: + self.fatal( + "INFRA-ERROR: insufficient Android bogomips (%d < %d)" + % (bogomips, bogomips_minimum), + EXIT_STATUS_DICT[TBPL_RETRY], + ) + self.info("Found Android bogomips: %d" % bogomips) + break + + def logcat_path(self): + logcat_filename = "logcat-%s.log" % self.device_serial + return os.path.join( + self.query_abs_dirs()["abs_blob_upload_dir"], logcat_filename + ) + + def logcat_start(self): + """ + Start recording logcat. Writes logcat to the upload directory. + """ + # Start logcat for the device. The adb process runs until the + # corresponding device is stopped. Output is written directly to + # the blobber upload directory so that it is uploaded automatically + # at the end of the job. + self.logcat_file = open(self.logcat_path(), "w") + logcat_cmd = [ + self.adb_path, + "-s", + self.device_serial, + "logcat", + "-v", + "threadtime", + "Trace:S", + "StrictMode:S", + "ExchangeService:S", + ] + self.info(" ".join(logcat_cmd)) + self.logcat_proc = subprocess.Popen( + logcat_cmd, stdout=self.logcat_file, stdin=subprocess.PIPE + ) + + def logcat_stop(self): + """ + Stop logcat process started by logcat_start. + """ + if self.logcat_proc: + self.info("Killing logcat pid %d." % self.logcat_proc.pid) + self.logcat_proc.kill() + self.logcat_file.close() + + def _install_android_app_retry(self, app_path, replace): + import mozdevice + + try: + if app_path.endswith(".aab"): + self.device.install_app_bundle( + self.query_abs_dirs()["abs_bundletool_path"], app_path, timeout=120 + ) + self.device.run_as_package = self.query_package_name() + else: + self.device.run_as_package = self.device.install_app( + app_path, replace=replace, timeout=120 + ) + return True + except ( + mozdevice.ADBError, + mozdevice.ADBProcessError, + mozdevice.ADBTimeoutError, + ) as e: + self.info( + "Failed to install %s on %s: %s %s" + % (app_path, self.device_name, type(e).__name__, e) + ) + return False + + def install_android_app(self, app_path, replace=False): + """ + Install the specified app. + """ + app_installed = self._retry( + 5, + 10, + functools.partial(self._install_android_app_retry, app_path, replace), + "Install app", + ) + + if not app_installed: + self.fatal( + "INFRA-ERROR: Failed to install %s" % os.path.basename(app_path), + EXIT_STATUS_DICT[TBPL_RETRY], + ) + + def uninstall_android_app(self): + """ + Uninstall the app associated with the configured app, if it is + installed. + """ + import mozdevice + + try: + package_name = self.query_package_name() + self.device.uninstall_app(package_name) + except ( + mozdevice.ADBError, + mozdevice.ADBProcessError, + mozdevice.ADBTimeoutError, + ) as e: + self.info( + "Failed to uninstall %s from %s: %s %s" + % (package_name, self.device_name, type(e).__name__, e) + ) + self.fatal( + "INFRA-ERROR: %s Failed to uninstall %s" + % (type(e).__name__, package_name), + EXIT_STATUS_DICT[TBPL_RETRY], + ) + + def is_boot_completed(self): + import mozdevice + + try: + return self.device.is_device_ready(timeout=30) + except (ValueError, mozdevice.ADBError, mozdevice.ADBTimeoutError): + pass + return False + + def shell_output(self, cmd, enable_run_as=False): + import mozdevice + + try: + return self.device.shell_output( + cmd, timeout=30, enable_run_as=enable_run_as + ) + except mozdevice.ADBTimeoutError as e: + self.info( + "Failed to run shell command %s from %s: %s %s" + % (cmd, self.device_name, type(e).__name__, e) + ) + self.fatal( + "INFRA-ERROR: %s Failed to run shell command %s" + % (type(e).__name__, cmd), + EXIT_STATUS_DICT[TBPL_RETRY], + ) + + def device_screenshot(self, prefix): + """ + On emulator, save a screenshot of the entire screen to the upload directory; + otherwise, save a screenshot of the device to the upload directory. + + :param prefix specifies a filename prefix for the screenshot + """ + from mozscreenshot import dump_device_screen, dump_screen + + reset_dir = False + if not os.environ.get("MOZ_UPLOAD_DIR", None): + dirs = self.query_abs_dirs() + os.environ["MOZ_UPLOAD_DIR"] = dirs["abs_blob_upload_dir"] + reset_dir = True + if self.is_emulator: + if self.xre_path: + dump_screen(self.xre_path, self, prefix=prefix) + else: + self.info("Not saving screenshot: no XRE configured") + else: + dump_device_screen(self.device, self, prefix=prefix) + if reset_dir: + del os.environ["MOZ_UPLOAD_DIR"] + + def download_hostutils(self, xre_dir): + """ + Download and install hostutils from tooltool. + """ + xre_path = None + self.rmtree(xre_dir) + self.mkdir_p(xre_dir) + if self.config["hostutils_manifest_path"]: + url = self._get_repo_url(self.config["hostutils_manifest_path"]) + self._tooltool_fetch(url, xre_dir) + for p in glob.glob(os.path.join(xre_dir, "host-utils-*")): + if os.path.isdir(p) and os.path.isfile(os.path.join(p, "xpcshell")): + xre_path = p + if not xre_path: + self.fatal("xre path not found in %s" % xre_dir) + else: + self.fatal("configure hostutils_manifest_path!") + return xre_path + + def query_package_name(self): + if self.app_name is None: + # For convenience, assume geckoview.test/geckoview_example when install + # target looks like geckoview. + if "androidTest" in self.installer_path: + self.app_name = "org.mozilla.geckoview.test" + elif "test_runner" in self.installer_path: + self.app_name = "org.mozilla.geckoview.test_runner" + elif "geckoview" in self.installer_path: + self.app_name = "org.mozilla.geckoview_example" + if self.app_name is None: + # Find appname from package-name.txt - assumes download-and-extract + # has completed successfully. + # The app/package name will typically be org.mozilla.fennec, + # but org.mozilla.firefox for release builds, and there may be + # other variations. 'aapt dump badging <apk>' could be used as an + # alternative to package-name.txt, but introduces a dependency + # on aapt, found currently in the Android SDK build-tools component. + app_dir = self.abs_dirs["abs_work_dir"] + self.app_path = os.path.join(app_dir, self.installer_path) + unzip = self.query_exe("unzip") + package_path = os.path.join(app_dir, "package-name.txt") + unzip_cmd = [unzip, "-q", "-o", self.app_path] + self.run_command(unzip_cmd, cwd=app_dir, halt_on_failure=True) + self.app_name = str( + self.read_from_file(package_path, verbose=True) + ).rstrip() + return self.app_name + + def kill_processes(self, process_name): + self.info("Killing every process called %s" % process_name) + process_name = six.ensure_binary(process_name) + out = subprocess.check_output(["ps", "-A"]) + for line in out.splitlines(): + if process_name in line: + pid = int(line.split(None, 1)[0]) + self.info("Killing pid %d." % pid) + os.kill(pid, signal.SIGKILL) + + def delete_ANRs(self): + remote_dir = self.device.stack_trace_dir + try: + if not self.device.is_dir(remote_dir): + self.device.mkdir(remote_dir) + self.info("%s created" % remote_dir) + return + self.device.chmod(remote_dir, recursive=True) + for trace_file in self.device.ls(remote_dir, recursive=True): + trace_path = posixpath.join(remote_dir, trace_file) + if self.device.is_file(trace_path): + self.device.rm(trace_path) + self.info("%s deleted" % trace_path) + except Exception as e: + self.info( + "failed to delete %s: %s %s" % (remote_dir, type(e).__name__, str(e)) + ) + + def check_for_ANRs(self): + """ + Copy ANR (stack trace) files from device to upload directory. + """ + dirs = self.query_abs_dirs() + remote_dir = self.device.stack_trace_dir + try: + if not self.device.is_dir(remote_dir): + self.info("%s not found; ANR check skipped" % remote_dir) + return + self.device.chmod(remote_dir, recursive=True) + self.device.pull(remote_dir, dirs["abs_blob_upload_dir"]) + self.delete_ANRs() + except Exception as e: + self.info( + "failed to pull %s: %s %s" % (remote_dir, type(e).__name__, str(e)) + ) + + def delete_tombstones(self): + remote_dir = "/data/tombstones" + try: + if not self.device.is_dir(remote_dir): + self.device.mkdir(remote_dir) + self.info("%s created" % remote_dir) + return + self.device.chmod(remote_dir, recursive=True) + for trace_file in self.device.ls(remote_dir, recursive=True): + trace_path = posixpath.join(remote_dir, trace_file) + if self.device.is_file(trace_path): + self.device.rm(trace_path) + self.info("%s deleted" % trace_path) + except Exception as e: + self.info( + "failed to delete %s: %s %s" % (remote_dir, type(e).__name__, str(e)) + ) + + def check_for_tombstones(self): + """ + Copy tombstone files from device to upload directory. + """ + dirs = self.query_abs_dirs() + remote_dir = "/data/tombstones" + try: + if not self.device.is_dir(remote_dir): + self.info("%s not found; tombstone check skipped" % remote_dir) + return + self.device.chmod(remote_dir, recursive=True) + self.device.pull(remote_dir, dirs["abs_blob_upload_dir"]) + self.delete_tombstones() + except Exception as e: + self.info( + "failed to pull %s: %s %s" % (remote_dir, type(e).__name__, str(e)) + ) + + # Script actions + + def start_emulator(self): + """ + Starts the emulator + """ + if not self.is_emulator: + return + + dirs = self.query_abs_dirs() + ensure_dir(dirs["abs_work_dir"]) + ensure_dir(dirs["abs_blob_upload_dir"]) + + if not os.path.isfile(self.adb_path): + self.fatal("The adb binary '%s' is not a valid file!" % self.adb_path) + self.kill_processes("xpcshell") + self.emulator_proc = self._launch_emulator() + + def verify_device(self): + """ + Check to see if the emulator can be contacted via adb. + If any communication attempt fails, kill the emulator, re-launch, and re-check. + """ + if not self.is_android: + return + + if self.is_emulator: + max_restarts = 5 + emulator_ok = self._retry( + max_restarts, + 10, + self._verify_emulator_and_restart_on_fail, + "Check emulator", + ) + if not emulator_ok: + self.fatal( + "INFRA-ERROR: Unable to start emulator after %d attempts" + % max_restarts, + EXIT_STATUS_DICT[TBPL_RETRY], + ) + + self.mkdir_p(self.query_abs_dirs()["abs_blob_upload_dir"]) + self.dump_perf_info() + self.logcat_start() + self.delete_ANRs() + self.delete_tombstones() + self.info("verify_device complete") + + @PreScriptAction("run-tests") + def timed_screenshots(self, action, success=None): + """ + If configured, start screenshot timers. + """ + if not self.is_android: + return + + def take_screenshot(seconds): + self.device_screenshot("screenshot-%ss-" % str(seconds)) + self.info("timed (%ss) screenshot complete" % str(seconds)) + + self.timers = [] + for seconds in self.config.get("screenshot_times", []): + self.info("screenshot requested %s seconds from now" % str(seconds)) + t = Timer(int(seconds), take_screenshot, [seconds]) + t.start() + self.timers.append(t) + + @PostScriptAction("run-tests") + def stop_device(self, action, success=None): + """ + Stop logcat and kill the emulator, if necessary. + """ + if not self.is_android: + return + + for t in self.timers: + t.cancel() + if self.worst_status != TBPL_RETRY: + self.check_for_ANRs() + self.check_for_tombstones() + else: + self.info("ANR and tombstone checks skipped due to TBPL_RETRY") + self.logcat_stop() + if self.is_emulator: + self.kill_processes(self.config["emulator_process_name"]) |