diff options
Diffstat (limited to 'testing/mozbase/mozrunner')
28 files changed, 3569 insertions, 0 deletions
diff --git a/testing/mozbase/mozrunner/mozrunner/__init__.py b/testing/mozbase/mozrunner/mozrunner/__init__.py new file mode 100644 index 0000000000..13cb5e428f --- /dev/null +++ b/testing/mozbase/mozrunner/mozrunner/__init__.py @@ -0,0 +1,12 @@ +# flake8: noqa +# 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/. + +import mozrunner.base +import mozrunner.devices +import mozrunner.utils + +from .cli import * +from .errors import * +from .runners import * diff --git a/testing/mozbase/mozrunner/mozrunner/application.py b/testing/mozbase/mozrunner/mozrunner/application.py new file mode 100644 index 0000000000..d1dd91e4c0 --- /dev/null +++ b/testing/mozbase/mozrunner/mozrunner/application.py @@ -0,0 +1,156 @@ +# 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/. + +import os +import posixpath +from abc import ABCMeta, abstractmethod +from distutils.spawn import find_executable + +import six +from mozdevice import ADBDeviceFactory +from mozprofile import ( + ChromeProfile, + ChromiumProfile, + FirefoxProfile, + Profile, + ThunderbirdProfile, +) + +here = os.path.abspath(os.path.dirname(__file__)) + + +def get_app_context(appname): + context_map = { + "chrome": ChromeContext, + "chromium": ChromiumContext, + "default": DefaultContext, + "fennec": FennecContext, + "firefox": FirefoxContext, + "thunderbird": ThunderbirdContext, + } + if appname not in context_map: + raise KeyError("Application '%s' not supported!" % appname) + return context_map[appname] + + +class DefaultContext(object): + profile_class = Profile + + +@six.add_metaclass(ABCMeta) +class RemoteContext(object): + device = None + _remote_profile = None + _adb = None + profile_class = Profile + _bindir = None + remote_test_root = "" + remote_process = None + + @property + def bindir(self): + if self._bindir is None: + paths = [find_executable("emulator")] + paths = [p for p in paths if p is not None if os.path.isfile(p)] + if not paths: + self._bindir = "" + else: + self._bindir = os.path.dirname(paths[0]) + return self._bindir + + @property + def adb(self): + if not self._adb: + paths = [ + os.environ.get("ADB"), + os.environ.get("ADB_PATH"), + self.which("adb"), + ] + paths = [p for p in paths if p is not None if os.path.isfile(p)] + if not paths: + raise OSError( + "Could not find the adb binary, make sure it is on your" + "path or set the $ADB_PATH environment variable." + ) + self._adb = paths[0] + return self._adb + + @property + def remote_profile(self): + if not self._remote_profile: + self._remote_profile = posixpath.join(self.remote_test_root, "profile") + return self._remote_profile + + def which(self, binary): + paths = os.environ.get("PATH", {}).split(os.pathsep) + if self.bindir is not None and os.path.abspath(self.bindir) not in paths: + paths.insert(0, os.path.abspath(self.bindir)) + os.environ["PATH"] = os.pathsep.join(paths) + + return find_executable(binary) + + @abstractmethod + def stop_application(self): + """Run (device manager) command to stop application.""" + pass + + +devices = {} + + +class FennecContext(RemoteContext): + _remote_profiles_ini = None + _remote_test_root = None + + def __init__(self, app=None, adb_path=None, avd_home=None, device_serial=None): + self._adb = adb_path + self.avd_home = avd_home + self.remote_process = app + self.device_serial = device_serial + self.device = self.get_device(self.adb, device_serial) + + def get_device(self, adb_path, device_serial): + # Create a mozdevice.ADBDevice object for the specified device_serial + # and cache it for future use. If the same device_serial is subsequently + # requested, retrieve it from the cache to avoid costly re-initialization. + global devices + if device_serial in devices: + device = devices[device_serial] + else: + device = ADBDeviceFactory(adb=adb_path, device=device_serial) + devices[device_serial] = device + return device + + def stop_application(self): + self.device.stop_application(self.remote_process) + + @property + def remote_test_root(self): + if not self._remote_test_root: + self._remote_test_root = self.device.test_root + return self._remote_test_root + + @property + def remote_profiles_ini(self): + if not self._remote_profiles_ini: + self._remote_profiles_ini = posixpath.join( + "/data", "data", self.remote_process, "files", "mozilla", "profiles.ini" + ) + return self._remote_profiles_ini + + +class FirefoxContext(object): + profile_class = FirefoxProfile + + +class ThunderbirdContext(object): + profile_class = ThunderbirdProfile + + +class ChromeContext(object): + profile_class = ChromeProfile + + +class ChromiumContext(object): + profile_class = ChromiumProfile diff --git a/testing/mozbase/mozrunner/mozrunner/base/__init__.py b/testing/mozbase/mozrunner/mozrunner/base/__init__.py new file mode 100644 index 0000000000..373dde1807 --- /dev/null +++ b/testing/mozbase/mozrunner/mozrunner/base/__init__.py @@ -0,0 +1,8 @@ +# 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/. + +# flake8: noqa +from .browser import BlinkRuntimeRunner, GeckoRuntimeRunner +from .device import DeviceRunner, FennecRunner +from .runner import BaseRunner diff --git a/testing/mozbase/mozrunner/mozrunner/base/browser.py b/testing/mozbase/mozrunner/mozrunner/base/browser.py new file mode 100644 index 0000000000..0d7b88adc5 --- /dev/null +++ b/testing/mozbase/mozrunner/mozrunner/base/browser.py @@ -0,0 +1,122 @@ +# 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/. + +import copy +import os +import sys + +import mozinfo + +from ..application import DefaultContext, FirefoxContext +from .runner import BaseRunner + + +class GeckoRuntimeRunner(BaseRunner): + """ + The base runner class used for local gecko runtime binaries, + such as Firefox and Thunderbird. + """ + + def __init__(self, binary, cmdargs=None, **runner_args): + self.show_crash_reporter = runner_args.pop("show_crash_reporter", False) + BaseRunner.__init__(self, **runner_args) + + self.binary = binary + self.cmdargs = copy.copy(cmdargs) or [] + + if ( + mozinfo.isWin + and ( + isinstance(self.app_ctx, FirefoxContext) + or isinstance(self.app_ctx, DefaultContext) + ) + and "--wait-for-browser" not in self.cmdargs + ): + # The launcher process is present in this configuration. Always + # pass this flag so that we can wait for the browser to complete + # its execution. + self.cmdargs.append("--wait-for-browser") + + # allows you to run an instance of Firefox separately from any other instances + self.env["MOZ_NO_REMOTE"] = "1" + + # Disable crash reporting dialogs that interfere with debugging + self.env["GNOME_DISABLE_CRASH_DIALOG"] = "1" + self.env["XRE_NO_WINDOWS_CRASH_DIALOG"] = "1" + + # set the library path if needed on linux + if sys.platform == "linux2" and self.binary.endswith("-bin"): + dirname = os.path.dirname(self.binary) + if os.environ.get("LD_LIBRARY_PATH", None): + self.env["LD_LIBRARY_PATH"] = "%s:%s" % ( + os.environ["LD_LIBRARY_PATH"], + dirname, + ) + else: + self.env["LD_LIBRARY_PATH"] = dirname + + @property + def command(self): + command = [self.binary, "-profile", self.profile.profile] + + _cmdargs = [i for i in self.cmdargs if i != "-foreground"] + if len(_cmdargs) != len(self.cmdargs): + # foreground should be last; see + # https://bugzilla.mozilla.org/show_bug.cgi?id=625614 + self.cmdargs = _cmdargs + self.cmdargs.append("-foreground") + if mozinfo.isMac and "-foreground" not in self.cmdargs: + # runner should specify '-foreground' on Mac; see + # https://bugzilla.mozilla.org/show_bug.cgi?id=916512 + self.cmdargs.append("-foreground") + + # Bug 775416 - Ensure that binary options are passed in first + command[1:1] = self.cmdargs + return command + + def start(self, *args, **kwargs): + # ensure the profile exists + if not self.profile.exists(): + self.profile.reset() + assert self.profile.exists(), ( + "%s : failure to reset profile" % self.__class__.__name__ + ) + + has_debugger = "debug_args" in kwargs and kwargs["debug_args"] + if has_debugger: + self.env["MOZ_CRASHREPORTER_DISABLE"] = "1" + else: + if not self.show_crash_reporter: + # hide the crash reporter window + self.env["MOZ_CRASHREPORTER_NO_REPORT"] = "1" + self.env["MOZ_CRASHREPORTER"] = "1" + + BaseRunner.start(self, *args, **kwargs) + + +class BlinkRuntimeRunner(BaseRunner): + """A base runner class for running apps like Google Chrome or Chromium.""" + + def __init__(self, binary, cmdargs=None, **runner_args): + super(BlinkRuntimeRunner, self).__init__(**runner_args) + self.binary = binary + self.cmdargs = cmdargs or [] + + data_dir, name = os.path.split(self.profile.profile) + profile_args = [ + "--user-data-dir={}".format(data_dir), + "--profile-directory={}".format(name), + "--no-first-run", + ] + self.cmdargs.extend(profile_args) + + @property + def command(self): + cmd = self.cmdargs[:] + if self.profile.addons: + cmd.append("--load-extension={}".format(",".join(self.profile.addons))) + return [self.binary] + cmd + + def check_for_crashes(self, *args, **kwargs): + raise NotImplementedError diff --git a/testing/mozbase/mozrunner/mozrunner/base/device.py b/testing/mozbase/mozrunner/mozrunner/base/device.py new file mode 100644 index 0000000000..bf3c5965ff --- /dev/null +++ b/testing/mozbase/mozrunner/mozrunner/base/device.py @@ -0,0 +1,199 @@ +# 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/. + +import codecs +import datetime +import re +import signal +import sys +import tempfile +import time + +import mozfile +import six + +from ..devices import BaseEmulator +from .runner import BaseRunner + + +class DeviceRunner(BaseRunner): + """ + The base runner class used for running gecko on + remote devices (or emulators). + """ + + env = { + "MOZ_CRASHREPORTER": "1", + "MOZ_CRASHREPORTER_NO_REPORT": "1", + "MOZ_CRASHREPORTER_SHUTDOWN": "1", + "MOZ_HIDE_RESULTS_TABLE": "1", + "MOZ_IN_AUTOMATION": "1", + "MOZ_LOG": "signaling:3,mtransport:4,DataChannel:4,jsep:4", + "R_LOG_LEVEL": "6", + "R_LOG_DESTINATION": "stderr", + "R_LOG_VERBOSE": "1", + } + + def __init__(self, device_class, device_args=None, **kwargs): + process_log = tempfile.NamedTemporaryFile(suffix="pidlog") + # the env will be passed to the device, it is not a *real* env + self._device_env = dict(DeviceRunner.env) + self._device_env["MOZ_PROCESS_LOG"] = process_log.name + # be sure we do not pass env to the parent class ctor + env = kwargs.pop("env", None) + if env: + self._device_env.update(env) + + if six.PY2: + stdout = codecs.getwriter("utf-8")(sys.stdout) + else: + stdout = codecs.getwriter("utf-8")(sys.stdout.buffer) + process_args = { + "stream": stdout, + "processOutputLine": self.on_output, + "onFinish": self.on_finish, + "onTimeout": self.on_timeout, + } + process_args.update(kwargs.get("process_args") or {}) + + kwargs["process_args"] = process_args + BaseRunner.__init__(self, **kwargs) + + device_args = device_args or {} + self.device = device_class(**device_args) + + @property + def command(self): + # command built by mozdevice -- see start() below + return None + + def start(self, *args, **kwargs): + if isinstance(self.device, BaseEmulator) and not self.device.connected: + self.device.start() + self.device.connect() + self.device.setup_profile(self.profile) + + app = self.app_ctx.remote_process + self.device.run_as_package = app + args = ["-no-remote", "-profile", self.app_ctx.remote_profile] + args.extend(self.cmdargs) + env = self._device_env + url = None + if "geckoview" in app: + activity = "TestRunnerActivity" + self.app_ctx.device.launch_activity( + app, activity, e10s=True, moz_env=env, extra_args=args, url=url + ) + else: + self.app_ctx.device.launch_fennec( + app, moz_env=env, extra_args=args, url=url + ) + + timeout = 10 # seconds + end_time = datetime.datetime.now() + datetime.timedelta(seconds=timeout) + while not self.is_running() and datetime.datetime.now() < end_time: + time.sleep(0.5) + if not self.is_running(): + print( + "timed out waiting for '%s' process to start" + % self.app_ctx.remote_process + ) + + def stop(self, sig=None): + if not sig and self.is_running(): + self.app_ctx.stop_application() + + if self.is_running(): + timeout = 10 + + self.app_ctx.device.pkill(self.app_ctx.remote_process, sig=sig) + if self.wait(timeout) is None and sig is not None: + print( + "timed out waiting for '{}' process to exit, trying " + "without signal {}".format(self.app_ctx.remote_process, sig) + ) + + # need to call adb stop otherwise the system will attempt to + # restart the process + self.app_ctx.stop_application() + if self.wait(timeout) is None: + print( + "timed out waiting for '{}' process to exit".format( + self.app_ctx.remote_process + ) + ) + + @property + def returncode(self): + """The returncode of the remote process. + + A value of None indicates the process is still running. Otherwise 0 is + returned, because there is no known way yet to retrieve the real exit code. + """ + if self.app_ctx.device.process_exist(self.app_ctx.remote_process): + return None + + return 0 + + def wait(self, timeout=None): + """Wait for the remote process to exit. + + :param timeout: if not None, will return after timeout seconds. + + :returns: the process return code or None if timeout was reached + and the process is still running. + """ + end_time = None + if timeout is not None: + end_time = datetime.datetime.now() + datetime.timedelta(seconds=timeout) + + while self.is_running(): + if end_time is not None and datetime.datetime.now() > end_time: + break + time.sleep(0.5) + + return self.returncode + + def on_output(self, line): + match = re.findall(r"TEST-START \| ([^\s]*)", line) + if match: + self.last_test = match[-1] + + def on_timeout(self): + self.stop(sig=signal.SIGABRT) + msg = "DeviceRunner TEST-UNEXPECTED-FAIL | %s | application timed out after %s seconds" + if self.timeout: + timeout = self.timeout + else: + timeout = self.output_timeout + msg = "%s with no output" % msg + + print(msg % (self.last_test, timeout)) + self.check_for_crashes() + + def on_finish(self): + self.check_for_crashes() + + def check_for_crashes(self, dump_save_path=None, test_name=None, **kwargs): + test_name = test_name or self.last_test + dump_dir = self.device.pull_minidumps() + crashed = BaseRunner.check_for_crashes( + self, + dump_directory=dump_dir, + dump_save_path=dump_save_path, + test_name=test_name, + **kwargs + ) + mozfile.remove(dump_dir) + return crashed + + def cleanup(self, *args, **kwargs): + BaseRunner.cleanup(self, *args, **kwargs) + self.device.cleanup() + + +class FennecRunner(DeviceRunner): + def __init__(self, cmdargs=None, **kwargs): + super(FennecRunner, self).__init__(**kwargs) + self.cmdargs = cmdargs or [] diff --git a/testing/mozbase/mozrunner/mozrunner/base/runner.py b/testing/mozbase/mozrunner/mozrunner/base/runner.py new file mode 100644 index 0000000000..e90813f918 --- /dev/null +++ b/testing/mozbase/mozrunner/mozrunner/base/runner.py @@ -0,0 +1,278 @@ +#!/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/. + +import os +import subprocess +import sys +import traceback +from abc import ABCMeta, abstractproperty + +import six +from mozlog import get_default_logger +from mozprocess import ProcessHandler +from six import ensure_str, string_types + +try: + import mozcrash +except ImportError: + mozcrash = None +from six import reraise + +from ..application import DefaultContext +from ..errors import RunnerNotStartedError + + +@six.add_metaclass(ABCMeta) +class BaseRunner(object): + """ + The base runner class for all mozrunner objects, both local and remote. + """ + + last_test = "mozrunner-startup" + process_handler = None + timeout = None + output_timeout = None + + def __init__( + self, + app_ctx=None, + profile=None, + clean_profile=True, + env=None, + process_class=None, + process_args=None, + symbols_path=None, + dump_save_path=None, + addons=None, + explicit_cleanup=False, + ): + self.app_ctx = app_ctx or DefaultContext() + + if isinstance(profile, string_types): + self.profile = self.app_ctx.profile_class(profile=profile, addons=addons) + else: + self.profile = profile or self.app_ctx.profile_class( + **getattr(self.app_ctx, "profile_args", {}) + ) + + self.logger = get_default_logger() + + # process environment + if env is None: + self.env = os.environ.copy() + else: + self.env = env.copy() + + self.clean_profile = clean_profile + self.process_class = process_class or ProcessHandler + self.process_args = process_args or {} + self.symbols_path = symbols_path + self.dump_save_path = dump_save_path + + self.crashed = 0 + self.explicit_cleanup = explicit_cleanup + + def __del__(self): + if not self.explicit_cleanup: + # If we're relying on the gc for cleanup do the same with the profile + self.cleanup(keep_profile=True) + + @abstractproperty + def command(self): + """Returns the command list to run.""" + pass + + @property + def returncode(self): + """ + The returncode of the process_handler. A value of None + indicates the process is still running. A negative + value indicates the process was killed with the + specified signal. + + :raises: RunnerNotStartedError + """ + if self.process_handler: + return self.process_handler.poll() + else: + raise RunnerNotStartedError("returncode accessed before runner started") + + def start( + self, debug_args=None, interactive=False, timeout=None, outputTimeout=None + ): + """ + Run self.command in the proper environment. + + :param debug_args: arguments for a debugger + :param interactive: uses subprocess.Popen directly + :param timeout: see process_handler.run() + :param outputTimeout: see process_handler.run() + :returns: the process id + + :raises: RunnerNotStartedError + """ + self.timeout = timeout + self.output_timeout = outputTimeout + cmd = self.command + + # ensure the runner is stopped + self.stop() + + # attach a debugger, if specified + if debug_args: + cmd = list(debug_args) + cmd + + if self.logger: + self.logger.info("Application command: %s" % " ".join(cmd)) + + str_env = {} + for k in self.env: + v = self.env[k] + str_env[ensure_str(k)] = ensure_str(v) + + if interactive: + self.process_handler = subprocess.Popen(cmd, env=str_env) + # TODO: other arguments + else: + # this run uses the managed processhandler + try: + process = self.process_class(cmd, env=str_env, **self.process_args) + process.run(self.timeout, self.output_timeout) + + self.process_handler = process + except Exception as e: + reraise( + RunnerNotStartedError, + RunnerNotStartedError("Failed to start the process: {}".format(e)), + sys.exc_info()[2], + ) + + self.crashed = 0 + return self.process_handler.pid + + def wait(self, timeout=None): + """ + Wait for the process to exit. + + :param timeout: if not None, will return after timeout seconds. + Timeout is ignored if interactive was set to True. + :returns: the process return code if process exited normally, + -<signal> if process was killed (Unix only), + None if timeout was reached and the process is still running. + :raises: RunnerNotStartedError + """ + if self.is_running(): + # The interactive mode uses directly a Popen process instance. It's + # wait() method doesn't have any parameters. So handle it separately. + if isinstance(self.process_handler, subprocess.Popen): + self.process_handler.wait() + else: + self.process_handler.wait(timeout) + + elif not self.process_handler: + raise RunnerNotStartedError("Wait() called before process started") + + return self.returncode + + def is_running(self): + """ + Checks if the process is running. + + :returns: True if the process is active + """ + return self.returncode is None + + def stop(self, sig=None, timeout=None): + """ + Kill the process. + + :param sig: Signal used to kill the process, defaults to SIGKILL + (has no effect on Windows). + :param timeout: Maximum time to wait for the processs to exit + (has no effect on Windows). + :returns: the process return code if process was already stopped, + -<signal> if process was killed (Unix only) + :raises: RunnerNotStartedError + """ + try: + if not self.is_running(): + return self.returncode + except RunnerNotStartedError: + return + + # The interactive mode uses directly a Popen process instance. It's + # kill() method doesn't have any parameters. So handle it separately. + if isinstance(self.process_handler, subprocess.Popen): + self.process_handler.kill() + else: + self.process_handler.kill(sig=sig, timeout=timeout) + + return self.returncode + + def reset(self): + """ + Reset the runner to its default state. + """ + self.stop() + self.process_handler = None + + def check_for_crashes( + self, dump_directory=None, dump_save_path=None, test_name=None, quiet=False + ): + """Check for possible crashes and output the stack traces. + + :param dump_directory: Directory to search for minidump files + :param dump_save_path: Directory to save the minidump files to + :param test_name: Name to use in the crash output + :param quiet: If `True` don't print the PROCESS-CRASH message to stdout + + :returns: Number of crashes which have been detected since the last invocation + """ + crash_count = 0 + + if not dump_directory: + dump_directory = os.path.join(self.profile.profile, "minidumps") + + if not dump_save_path: + dump_save_path = self.dump_save_path + + if not test_name: + test_name = "runner.py" + + try: + if self.logger: + if mozcrash: + crash_count = mozcrash.log_crashes( + self.logger, + dump_directory, + self.symbols_path, + dump_save_path=dump_save_path, + test=test_name, + ) + else: + self.logger.warning("Can not log crashes without mozcrash") + else: + if mozcrash: + crash_count = mozcrash.check_for_crashes( + dump_directory, + self.symbols_path, + dump_save_path=dump_save_path, + test_name=test_name, + quiet=quiet, + ) + + self.crashed += crash_count + except Exception: + traceback.print_exc() + + return crash_count + + def cleanup(self, keep_profile=False): + """ + Cleanup all runner state + """ + self.stop() + if not keep_profile: + self.profile.cleanup() diff --git a/testing/mozbase/mozrunner/mozrunner/cli.py b/testing/mozbase/mozrunner/mozrunner/cli.py new file mode 100644 index 0000000000..941734de5e --- /dev/null +++ b/testing/mozbase/mozrunner/mozrunner/cli.py @@ -0,0 +1,181 @@ +# 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/. + +import os +import sys + +from mozprofile import MozProfileCLI + +from .application import get_app_context +from .runners import runners +from .utils import findInPath + +# Map of debugging programs to information about them +# from http://mxr.mozilla.org/mozilla-central/source/build/automationutils.py#59 +DEBUGGERS = { + "gdb": { + "interactive": True, + "args": ["-q", "--args"], + }, + "valgrind": {"interactive": False, "args": ["--leak-check=full"]}, +} + + +def debugger_arguments(debugger, arguments=None, interactive=None): + """Finds debugger arguments from debugger given and defaults + + :param debugger: name or path to debugger + :param arguments: arguments for the debugger, or None to use defaults + :param interactive: whether the debugger should run in interactive mode + + """ + # find debugger executable if not a file + executable = debugger + if not os.path.exists(executable): + executable = findInPath(debugger) + if executable is None: + raise Exception("Path to '%s' not found" % debugger) + + # if debugger not in dictionary of knowns return defaults + dirname, debugger = os.path.split(debugger) + if debugger not in DEBUGGERS: + return ([executable] + (arguments or []), bool(interactive)) + + # otherwise use the dictionary values for arguments unless specified + if arguments is None: + arguments = DEBUGGERS[debugger].get("args", []) + if interactive is None: + interactive = DEBUGGERS[debugger].get("interactive", False) + return ([executable] + arguments, interactive) + + +class CLI(MozProfileCLI): + """Command line interface""" + + module = "mozrunner" + + def __init__(self, args=sys.argv[1:]): + MozProfileCLI.__init__(self, args=args) + + # choose appropriate runner and profile classes + app = self.options.app + try: + self.runner_class = runners[app] + self.profile_class = get_app_context(app).profile_class + except KeyError: + self.parser.error( + 'Application "%s" unknown (should be one of "%s")' + % (app, ", ".join(runners.keys())) + ) + + def add_options(self, parser): + """add options to the parser""" + parser.description = ( + "Reliable start/stop/configuration of Mozilla" + " Applications (Firefox, Thunderbird, etc.)" + ) + + # add profile options + MozProfileCLI.add_options(self, parser) + + # add runner options + parser.add_option( + "-b", + "--binary", + dest="binary", + help="Binary path.", + metavar=None, + default=None, + ) + parser.add_option( + "--app", + dest="app", + default="firefox", + help="Application to use [DEFAULT: %default]", + ) + parser.add_option( + "--app-arg", + dest="appArgs", + default=[], + action="append", + help="provides an argument to the test application", + ) + parser.add_option( + "--debugger", + dest="debugger", + help="run under a debugger, e.g. gdb or valgrind", + ) + parser.add_option( + "--debugger-args", + dest="debugger_args", + action="store", + help="arguments to the debugger", + ) + parser.add_option( + "--interactive", + dest="interactive", + action="store_true", + help="run the program interactively", + ) + + # methods for running + + def command_args(self): + """additional arguments for the mozilla application""" + # pylint --py3k: W1636 + return list(map(os.path.expanduser, self.options.appArgs)) + + def runner_args(self): + """arguments to instantiate the runner class""" + return dict(cmdargs=self.command_args(), binary=self.options.binary) + + def create_runner(self): + profile = self.profile_class(**self.profile_args()) + return self.runner_class(profile=profile, **self.runner_args()) + + def run(self): + runner = self.create_runner() + self.start(runner) + runner.cleanup() + + def debugger_arguments(self): + """Get the debugger arguments + + returns a 2-tuple of debugger arguments: + (debugger_arguments, interactive) + + """ + debug_args = self.options.debugger_args + if debug_args is not None: + debug_args = debug_args.split() + interactive = self.options.interactive + if self.options.debugger: + debug_args, interactive = debugger_arguments( + self.options.debugger, debug_args, interactive + ) + return debug_args, interactive + + def start(self, runner): + """Starts the runner and waits for the application to exit + + It can also happen via a keyboard interrupt. It should be + overwritten to provide custom running of the runner instance. + + """ + # attach a debugger if specified + debug_args, interactive = self.debugger_arguments() + runner.start(debug_args=debug_args, interactive=interactive) + print("Starting: " + " ".join(runner.command)) + try: + runner.wait() + except KeyboardInterrupt: + runner.stop() + + +def cli(args=sys.argv[1:]): + CLI(args).run() + + +if __name__ == "__main__": + cli() diff --git a/testing/mozbase/mozrunner/mozrunner/devices/__init__.py b/testing/mozbase/mozrunner/mozrunner/devices/__init__.py new file mode 100644 index 0000000000..7a4ec02e19 --- /dev/null +++ b/testing/mozbase/mozrunner/mozrunner/devices/__init__.py @@ -0,0 +1,17 @@ +# 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 mozrunner.devices import emulator_battery, emulator_geo, emulator_screen + +from .base import Device +from .emulator import BaseEmulator, EmulatorAVD + +__all__ = [ + "BaseEmulator", + "EmulatorAVD", + "Device", + "emulator_battery", + "emulator_geo", + "emulator_screen", +] diff --git a/testing/mozbase/mozrunner/mozrunner/devices/android_device.py b/testing/mozbase/mozrunner/mozrunner/devices/android_device.py new file mode 100644 index 0000000000..e84226da60 --- /dev/null +++ b/testing/mozbase/mozrunner/mozrunner/devices/android_device.py @@ -0,0 +1,1063 @@ +# 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/. + +import glob +import os +import platform +import posixpath +import re +import shutil +import signal +import subprocess +import sys +import telnetlib +import time +from distutils.spawn import find_executable +from enum import Enum + +import six +from mozdevice import ADBDeviceFactory, ADBHost +from six.moves import input, urllib + +MOZBUILD_PATH = os.environ.get( + "MOZBUILD_STATE_PATH", os.path.expanduser(os.path.join("~", ".mozbuild")) +) + +EMULATOR_HOME_DIR = os.path.join(MOZBUILD_PATH, "android-device") + +EMULATOR_AUTH_FILE = os.path.join( + os.path.expanduser("~"), ".emulator_console_auth_token" +) + +TOOLTOOL_PATH = "testing/mozharness/external_tools/tooltool.py" + +TRY_URL = "https://hg.mozilla.org/try/raw-file/default" + +MANIFEST_PATH = "testing/config/tooltool-manifests" + +SHORT_TIMEOUT = 10 + +verbose_logging = False + +LLDB_SERVER_INSTALL_COMMANDS_SCRIPT = """ +umask 0002 + +mkdir -p {lldb_bin_dir} + +cp /data/local/tmp/lldb-server {lldb_bin_dir} +chmod +x {lldb_bin_dir}/lldb-server + +chmod 0775 {lldb_dir} +""".lstrip() + +LLDB_SERVER_START_COMMANDS_SCRIPT = """ +umask 0002 + +export LLDB_DEBUGSERVER_LOG_FILE={lldb_log_file} +export LLDB_SERVER_LOG_CHANNELS="{lldb_log_channels}" +export LLDB_DEBUGSERVER_DOMAINSOCKET_DIR={socket_dir} + +rm -rf {lldb_tmp_dir} +mkdir {lldb_tmp_dir} +export TMPDIR={lldb_tmp_dir} + +rm -rf {lldb_log_dir} +mkdir {lldb_log_dir} + +touch {lldb_log_file} +touch {platform_log_file} + +cd {lldb_tmp_dir} +{lldb_bin_dir}/lldb-server platform --server --listen {listener_scheme}://{socket_file} \\ + --log-file "{platform_log_file}" --log-channels "{lldb_log_channels}" \\ + < /dev/null > {platform_stdout_log_file} 2>&1 & +""".lstrip() + + +class InstallIntent(Enum): + YES = 1 + NO = 2 + + +class AvdInfo(object): + """ + Simple class to contain an AVD description. + """ + + def __init__(self, description, name, extra_args, x86): + self.description = description + self.name = name + self.extra_args = extra_args + self.x86 = x86 + + +""" + A dictionary to map an AVD type to a description of that type of AVD. + + There is one entry for each type of AVD used in Mozilla automated tests + and the parameters for each reflect those used in mozharness. +""" +AVD_DICT = { + "arm": AvdInfo( + "Android arm", + "mozemulator-armeabi-v7a", + [ + "-skip-adb-auth", + "-verbose", + "-show-kernel", + "-ranchu", + "-selinux", + "permissive", + "-memory", + "3072", + "-cores", + "4", + "-skin", + "800x1280", + "-gpu", + "on", + "-no-snapstorage", + "-no-snapshot", + "-prop", + "ro.test_harness=true", + ], + False, + ), + "arm64": AvdInfo( + "Android arm64", + "mozemulator-arm64", + [ + "-skip-adb-auth", + "-verbose", + "-show-kernel", + "-ranchu", + "-selinux", + "permissive", + "-memory", + "3072", + "-cores", + "4", + "-skin", + "800x1280", + "-gpu", + "on", + "-no-snapstorage", + "-no-snapshot", + "-prop", + "ro.test_harness=true", + ], + False, + ), + "x86_64": AvdInfo( + "Android x86_64", + "mozemulator-x86_64", + [ + "-skip-adb-auth", + "-verbose", + "-show-kernel", + "-ranchu", + "-selinux", + "permissive", + "-memory", + "3072", + "-cores", + "4", + "-skin", + "800x1280", + "-prop", + "ro.test_harness=true", + "-no-snapstorage", + "-no-snapshot", + ], + True, + ), +} + + +def _get_device(substs, device_serial=None): + + adb_path = _find_sdk_exe(substs, "adb", False) + if not adb_path: + adb_path = "adb" + device = ADBDeviceFactory( + adb=adb_path, verbose=verbose_logging, device=device_serial + ) + return device + + +def _install_host_utils(build_obj): + _log_info("Installing host utilities...") + installed = False + host_platform = _get_host_platform() + if host_platform: + path = os.path.join(build_obj.topsrcdir, MANIFEST_PATH) + path = os.path.join(path, host_platform, "hostutils.manifest") + _get_tooltool_manifest( + build_obj.substs, path, EMULATOR_HOME_DIR, "releng.manifest" + ) + _tooltool_fetch(build_obj.substs) + xre_path = glob.glob(os.path.join(EMULATOR_HOME_DIR, "host-utils*")) + for path in xre_path: + if os.path.isdir(path) and os.path.isfile( + os.path.join(path, _get_xpcshell_name()) + ): + os.environ["MOZ_HOST_BIN"] = path + installed = True + elif os.path.isfile(path): + os.remove(path) + if not installed: + _log_warning("Unable to install host utilities.") + else: + _log_warning( + "Unable to install host utilities -- your platform is not supported!" + ) + + +def _get_xpcshell_name(): + """ + Returns the xpcshell binary's name as a string (dependent on operating system). + """ + xpcshell_binary = "xpcshell" + if os.name == "nt": + xpcshell_binary = "xpcshell.exe" + return xpcshell_binary + + +def _maybe_update_host_utils(build_obj): + """ + Compare the installed host-utils to the version name in the manifest; + if the installed version is older, offer to update. + """ + + # Determine existing/installed version + existing_path = None + xre_paths = glob.glob(os.path.join(EMULATOR_HOME_DIR, "host-utils*")) + for path in xre_paths: + if os.path.isdir(path) and os.path.isfile( + os.path.join(path, _get_xpcshell_name()) + ): + existing_path = path + break + if existing_path is None: + # if not installed, no need to upgrade (new version will be installed) + return + existing_version = os.path.basename(existing_path) + + # Determine manifest version + manifest_version = None + host_platform = _get_host_platform() + if host_platform: + # Extract tooltool file name from manifest, something like: + # "filename": "host-utils-58.0a1.en-US-linux-x86_64.tar.gz", + path = os.path.join(build_obj.topsrcdir, MANIFEST_PATH) + manifest_path = os.path.join(path, host_platform, "hostutils.manifest") + with open(manifest_path, "r") as f: + for line in f.readlines(): + m = re.search('.*"(host-utils-.*)"', line) + if m: + manifest_version = m.group(1) + break + + # Compare, prompt, update + if existing_version and manifest_version: + hu_version_regex = "host-utils-([\d\.]*)" + manifest_version = float(re.search(hu_version_regex, manifest_version).group(1)) + existing_version = float(re.search(hu_version_regex, existing_version).group(1)) + if existing_version < manifest_version: + _log_info("Your host utilities are out of date!") + _log_info( + "You have %s installed, but %s is available" + % (existing_version, manifest_version) + ) + response = input("Update host utilities? (Y/n) ").strip() + if response.lower().startswith("y") or response == "": + parts = os.path.split(existing_path) + backup_dir = "_backup-" + parts[1] + backup_path = os.path.join(parts[0], backup_dir) + shutil.move(existing_path, backup_path) + _install_host_utils(build_obj) + + +def verify_android_device( + build_obj, + install=InstallIntent.NO, + xre=False, + debugger=False, + network=False, + verbose=False, + app=None, + device_serial=None, + aab=False, +): + """ + Determine if any Android device is connected via adb. + If no device is found, prompt to start an emulator. + If a device is found or an emulator started and 'install' is + specified, also check whether Firefox is installed on the + device; if not, prompt to install Firefox. + If 'xre' is specified, also check with MOZ_HOST_BIN is set + to a valid xre/host-utils directory; if not, prompt to set + one up. + If 'debugger' is specified, also check that lldb-server is installed; + if it is not found, set it up. + If 'network' is specified, also check that the device has basic + network connectivity. + Returns True if the emulator was started or another device was + already connected. + """ + if "MOZ_DISABLE_ADB_INSTALL" in os.environ: + install = InstallIntent.NO + _log_info( + "Found MOZ_DISABLE_ADB_INSTALL in environment, skipping android app" + "installation" + ) + device_verified = False + emulator = AndroidEmulator("*", substs=build_obj.substs, verbose=verbose) + adb_path = _find_sdk_exe(build_obj.substs, "adb", False) + if not adb_path: + adb_path = "adb" + adbhost = ADBHost(adb=adb_path, verbose=verbose, timeout=SHORT_TIMEOUT) + devices = adbhost.devices(timeout=SHORT_TIMEOUT) + if "device" in [d["state"] for d in devices]: + device_verified = True + elif emulator.is_available(): + response = input( + "No Android devices connected. Start an emulator? (Y/n) " + ).strip() + if response.lower().startswith("y") or response == "": + if not emulator.check_avd(): + _log_info("Android AVD not found, please run |mach bootstrap|") + return + _log_info( + "Starting emulator running %s..." % emulator.get_avd_description() + ) + emulator.start() + emulator.wait_for_start() + device_verified = True + + if device_verified and "DEVICE_SERIAL" not in os.environ: + devices = adbhost.devices(timeout=SHORT_TIMEOUT) + for d in devices: + if d["state"] == "device": + os.environ["DEVICE_SERIAL"] = d["device_serial"] + break + + if device_verified and install != InstallIntent.NO: + # Determine if test app is installed on the device; if not, + # prompt to install. This feature allows a test command to + # launch an emulator, install the test app, and proceed with testing + # in one operation. It is also a basic safeguard against other + # cases where testing is requested but test app installation has + # been forgotten. + # If a test app is installed, there is no way to determine whether + # the current build is installed, and certainly no way to + # determine if the installed build is the desired build. + # Installing every time (without prompting) is problematic because: + # - it prevents testing against other builds (downloaded apk) + # - installation may take a couple of minutes. + if not app: + app = "org.mozilla.geckoview.test_runner" + device = _get_device(build_obj.substs, device_serial) + response = "" + installed = device.is_app_installed(app) + + if not installed: + _log_info("It looks like %s is not installed on this device." % app) + if "fennec" in app or "firefox" in app: + if installed: + device.uninstall_app(app) + _log_info("Installing Firefox...") + build_obj._run_make(directory=".", target="install", ensure_exit_code=False) + elif app == "org.mozilla.geckoview.test": + if installed: + device.uninstall_app(app) + _log_info("Installing geckoview AndroidTest...") + build_obj._mach_context.commands.dispatch( + "android", + build_obj._mach_context, + subcommand="install-geckoview-test", + args=[], + ) + elif app == "org.mozilla.geckoview.test_runner": + if installed: + device.uninstall_app(app) + _log_info("Installing geckoview test_runner...") + sub = ( + "install-geckoview-test_runner-aab" + if aab + else "install-geckoview-test_runner" + ) + build_obj._mach_context.commands.dispatch( + "android", build_obj._mach_context, subcommand=sub, args=[] + ) + elif app == "org.mozilla.geckoview_example": + if installed: + device.uninstall_app(app) + _log_info("Installing geckoview_example...") + sub = ( + "install-geckoview_example-aab" if aab else "install-geckoview_example" + ) + build_obj._mach_context.commands.dispatch( + "android", build_obj._mach_context, subcommand=sub, args=[] + ) + elif not installed: + response = input( + "It looks like %s is not installed on this device,\n" + "but I don't know how to install it.\n" + "Install it now, then hit Enter " % app + ) + + device.run_as_package = app + + if device_verified and xre: + # Check whether MOZ_HOST_BIN has been set to a valid xre; if not, + # prompt to install one. + xre_path = os.environ.get("MOZ_HOST_BIN") + err = None + if not xre_path: + err = ( + "environment variable MOZ_HOST_BIN is not set to a directory " + "containing host xpcshell" + ) + elif not os.path.isdir(xre_path): + err = "$MOZ_HOST_BIN does not specify a directory" + elif not os.path.isfile(os.path.join(xre_path, _get_xpcshell_name())): + err = "$MOZ_HOST_BIN/xpcshell does not exist" + if err: + _maybe_update_host_utils(build_obj) + xre_path = glob.glob(os.path.join(EMULATOR_HOME_DIR, "host-utils*")) + for path in xre_path: + if os.path.isdir(path) and os.path.isfile( + os.path.join(path, _get_xpcshell_name()) + ): + os.environ["MOZ_HOST_BIN"] = path + err = None + break + if err: + _log_info("Host utilities not found: %s" % err) + response = input("Download and setup your host utilities? (Y/n) ").strip() + if response.lower().startswith("y") or response == "": + _install_host_utils(build_obj) + + if device_verified and network: + # Optionally check the network: If on a device that does not look like + # an emulator, verify that the device IP address can be obtained + # and check that this host can ping the device. + serial = device_serial or os.environ.get("DEVICE_SERIAL") + if not serial or ("emulator" not in serial): + device = _get_device(build_obj.substs, serial) + device.run_as_package = app + try: + addr = device.get_ip_address() + if not addr: + _log_warning("unable to get Android device's IP address!") + _log_warning( + "tests may fail without network connectivity to the device!" + ) + else: + _log_info("Android device's IP address: %s" % addr) + response = subprocess.check_output(["ping", "-c", "1", addr]) + _log_debug(response) + except Exception as e: + _log_warning( + "unable to verify network connection to device: %s" % str(e) + ) + _log_warning( + "tests may fail without network connectivity to the device!" + ) + else: + _log_debug("network check skipped on emulator") + + if debugger: + _setup_or_run_lldb_server(app, build_obj.substs, device_serial, setup=True) + + return device_verified + + +def run_lldb_server(app, substs, device_serial): + return _setup_or_run_lldb_server(app, substs, device_serial, setup=False) + + +def _setup_or_run_lldb_server(app, substs, device_serial, setup=True): + device = _get_device(substs, device_serial) + + # Don't use enable_run_as here, as this will not give you what you + # want if we have root access on the device. + pkg_dir = device.shell_output("run-as %s pwd" % app) + if not pkg_dir or pkg_dir == "/": + pkg_dir = "/data/data/%s" % app + _log_warning( + "Unable to resolve data directory for package %s, falling back to hardcoded path" + % app + ) + + pkg_lldb_dir = posixpath.join(pkg_dir, "lldb") + pkg_lldb_bin_dir = posixpath.join(pkg_lldb_dir, "bin") + pkg_lldb_server = posixpath.join(pkg_lldb_bin_dir, "lldb-server") + + if setup: + # Check whether lldb-server is already there + if device.shell_bool("test -x %s" % pkg_lldb_server, enable_run_as=True): + _log_info( + "Found lldb-server binary, terminating any running server processes..." + ) + # lldb-server is already present. Kill any running server. + device.shell("pkill -f lldb-server", enable_run_as=True) + else: + _log_info("lldb-server not found, installing...") + + # We need to do an install + try: + server_path_local = substs["ANDROID_LLDB_SERVER"] + except KeyError: + _log_info( + "ANDROID_LLDB_SERVER is not configured correctly; " + "please re-configure your build." + ) + return + + device.push(server_path_local, "/data/local/tmp") + + install_cmds = LLDB_SERVER_INSTALL_COMMANDS_SCRIPT.format( + lldb_bin_dir=pkg_lldb_bin_dir, lldb_dir=pkg_lldb_dir + ) + + install_cmds = [l for l in install_cmds.splitlines() if l] + + _log_debug( + "Running the following installation commands:\n%r" % (install_cmds,) + ) + + device.batch_execute(install_cmds, enable_run_as=True) + return + + pkg_lldb_sock_file = posixpath.join(pkg_dir, "platform-%d.sock" % int(time.time())) + + pkg_lldb_log_dir = posixpath.join(pkg_lldb_dir, "log") + pkg_lldb_tmp_dir = posixpath.join(pkg_lldb_dir, "tmp") + + pkg_lldb_log_file = posixpath.join(pkg_lldb_log_dir, "lldb-server.log") + pkg_platform_log_file = posixpath.join(pkg_lldb_log_dir, "platform.log") + pkg_platform_stdout_log_file = posixpath.join( + pkg_lldb_log_dir, "platform-stdout.log" + ) + + listener_scheme = "unix-abstract" + log_channels = "lldb process:gdb-remote packets" + + start_cmds = LLDB_SERVER_START_COMMANDS_SCRIPT.format( + lldb_bin_dir=pkg_lldb_bin_dir, + lldb_log_file=pkg_lldb_log_file, + lldb_log_channels=log_channels, + socket_dir=pkg_dir, + lldb_tmp_dir=pkg_lldb_tmp_dir, + lldb_log_dir=pkg_lldb_log_dir, + platform_log_file=pkg_platform_log_file, + listener_scheme=listener_scheme, + platform_stdout_log_file=pkg_platform_stdout_log_file, + socket_file=pkg_lldb_sock_file, + ) + + start_cmds = [l for l in start_cmds.splitlines() if l] + + _log_debug("Running the following start commands:\n%r" % (start_cmds,)) + + device.batch_execute(start_cmds, enable_run_as=True) + + return pkg_lldb_sock_file + + +def get_adb_path(build_obj): + return _find_sdk_exe(build_obj.substs, "adb", False) + + +def grant_runtime_permissions(build_obj, app, device_serial=None): + """ + Grant required runtime permissions to the specified app + (eg. org.mozilla.geckoview.test_runner). + """ + device = _get_device(build_obj.substs, device_serial) + device.run_as_package = app + device.grant_runtime_permissions(app) + + +class AndroidEmulator(object): + """ + Support running the Android emulator with an AVD from Mozilla + test automation. + + Example usage: + emulator = AndroidEmulator() + if not emulator.is_running() and emulator.is_available(): + if not emulator.check_avd(): + print("Android Emulator AVD not found, please run |mach bootstrap|") + emulator.start() + emulator.wait_for_start() + emulator.wait() + """ + + def __init__(self, avd_type=None, verbose=False, substs=None, device_serial=None): + global verbose_logging + self.emulator_log = None + self.emulator_path = "emulator" + verbose_logging = verbose + self.substs = substs + self.avd_type = self._get_avd_type(avd_type) + self.avd_info = AVD_DICT[self.avd_type] + self.gpu = True + self.restarted = False + self.device_serial = device_serial + self.avd_path = os.path.join( + EMULATOR_HOME_DIR, "avd", "%s.avd" % self.avd_info.name + ) + _log_debug("Running on %s" % platform.platform()) + _log_debug("Emulator created with type %s" % self.avd_type) + + def __del__(self): + if self.emulator_log: + self.emulator_log.close() + + def is_running(self): + """ + Returns True if the Android emulator is running. + """ + adb_path = _find_sdk_exe(self.substs, "adb", False) + if not adb_path: + adb_path = "adb" + adbhost = ADBHost(adb=adb_path, verbose=verbose_logging, timeout=SHORT_TIMEOUT) + devs = adbhost.devices(timeout=SHORT_TIMEOUT) + devs = [(d["device_serial"], d["state"]) for d in devs] + _log_debug(devs) + if ("emulator-5554", "device") in devs: + return True + return False + + def is_available(self): + """ + Returns True if an emulator executable is found. + """ + found = False + emulator_path = _find_sdk_exe(self.substs, "emulator", True) + if emulator_path: + self.emulator_path = emulator_path + found = True + return found + + def check_avd(self): + """ + Determine if the AVD is already installed locally. + + Returns True if the AVD is installed. + """ + if os.path.exists(self.avd_path): + _log_debug("AVD found at %s" % self.avd_path) + return True + _log_warning("Could not find AVD at %s" % self.avd_path) + return False + + def start(self, gpu_arg=None): + """ + Launch the emulator. + """ + if self.avd_info.x86 and "linux" in _get_host_platform(): + _verify_kvm(self.substs) + if os.path.exists(EMULATOR_AUTH_FILE): + os.remove(EMULATOR_AUTH_FILE) + _log_debug("deleted %s" % EMULATOR_AUTH_FILE) + self._update_avd_paths() + # create an empty auth file to disable emulator authentication + auth_file = open(EMULATOR_AUTH_FILE, "w") + auth_file.close() + + env = os.environ + env["ANDROID_EMULATOR_HOME"] = EMULATOR_HOME_DIR + env["ANDROID_AVD_HOME"] = os.path.join(EMULATOR_HOME_DIR, "avd") + command = [self.emulator_path, "-avd", self.avd_info.name] + override = os.environ.get("MOZ_EMULATOR_COMMAND_ARGS") + if override: + command += override.split() + _log_debug("Found MOZ_EMULATOR_COMMAND_ARGS in env: %s" % override) + else: + if gpu_arg: + command += ["-gpu", gpu_arg] + # Clear self.gpu to avoid our restart-without-gpu feature: if a specific + # gpu setting is requested, try to use that, and nothing else. + self.gpu = False + elif self.gpu: + command += ["-gpu", "on"] + if self.avd_info.extra_args: + # -enable-kvm option is not valid on OSX and Windows + if ( + _get_host_platform() in ("macosx64", "win32") + and "-enable-kvm" in self.avd_info.extra_args + ): + self.avd_info.extra_args.remove("-enable-kvm") + command += self.avd_info.extra_args + log_path = os.path.join(EMULATOR_HOME_DIR, "emulator.log") + self.emulator_log = open(log_path, "w+") + _log_debug("Starting the emulator with this command: %s" % " ".join(command)) + _log_debug("Emulator output will be written to '%s'" % log_path) + self.proc = subprocess.Popen( + command, + env=env, + stdin=subprocess.PIPE, + stdout=self.emulator_log, + stderr=self.emulator_log, + ) + _log_debug("Emulator started with pid %d" % int(self.proc.pid)) + + def wait_for_start(self): + """ + Verify that the emulator is running, the emulator device is visible + to adb, and Android has booted. + """ + if not self.proc: + _log_warning("Emulator not started!") + return False + if self.check_completed(): + return False + _log_debug("Waiting for device status...") + adb_path = _find_sdk_exe(self.substs, "adb", False) + if not adb_path: + adb_path = "adb" + adbhost = ADBHost(adb=adb_path, verbose=verbose_logging, timeout=SHORT_TIMEOUT) + devs = adbhost.devices(timeout=SHORT_TIMEOUT) + devs = [(d["device_serial"], d["state"]) for d in devs] + while ("emulator-5554", "device") not in devs: + time.sleep(10) + if self.check_completed(): + return False + devs = adbhost.devices(timeout=SHORT_TIMEOUT) + devs = [(d["device_serial"], d["state"]) for d in devs] + _log_debug("Device status verified.") + + _log_debug("Checking that Android has booted...") + device = _get_device(self.substs, self.device_serial) + complete = False + while not complete: + output = "" + try: + output = device.get_prop("sys.boot_completed", timeout=5) + except Exception: + # adb not yet responding...keep trying + pass + if output.strip() == "1": + complete = True + else: + time.sleep(10) + if self.check_completed(): + return False + _log_debug("Android boot status verified.") + + if not self._verify_emulator(): + return False + if self.avd_info.x86: + _log_info( + "Running the x86/x86_64 emulator; be sure to install an x86 or x86_64 APK!" + ) + else: + _log_info("Running the arm emulator; be sure to install an arm APK!") + return True + + def check_completed(self): + if self.proc.poll() is not None: + if self.gpu: + try: + for line in self.emulator_log.readlines(): + if ( + "Invalid value for -gpu" in line + or "Invalid GPU mode" in line + ): + self.gpu = False + break + except Exception as e: + _log_warning(str(e)) + + if not self.gpu and not self.restarted: + _log_warning( + "Emulator failed to start. Your emulator may be out of date." + ) + _log_warning("Trying to restart the emulator without -gpu argument.") + self.restarted = True + self.start() + return False + _log_warning("Emulator has already completed!") + log_path = os.path.join(EMULATOR_HOME_DIR, "emulator.log") + _log_warning( + "See log at %s and/or use --verbose for more information." % log_path + ) + return True + return False + + def wait(self): + """ + Wait for the emulator to close. If interrupted, close the emulator. + """ + try: + self.proc.wait() + except Exception: + if self.proc.poll() is None: + self.cleanup() + return self.proc.poll() + + def cleanup(self): + """ + Close the emulator. + """ + self.proc.kill(signal.SIGTERM) + + def get_avd_description(self): + """ + Return the human-friendly description of this AVD. + """ + return self.avd_info.description + + def _update_avd_paths(self): + ini_path = os.path.join(EMULATOR_HOME_DIR, "avd", "%s.ini" % self.avd_info.name) + with open(ini_path, "r") as f: + lines = f.readlines() + with open(ini_path, "w") as f: + for line in lines: + if line.startswith("path="): + f.write("path=%s\n" % self.avd_path) + elif line.startswith("path.rel="): + f.write("path.rel=avd/%s.avd\n" % self.avd_info.name) + else: + f.write(line) + + def _telnet_read_until(self, telnet, expected, timeout): + if six.PY3 and isinstance(expected, six.text_type): + expected = expected.encode("ascii") + return telnet.read_until(expected, timeout) + + def _telnet_write(self, telnet, command): + if six.PY3 and isinstance(command, six.text_type): + command = command.encode("ascii") + telnet.write(command) + + def _telnet_cmd(self, telnet, command): + _log_debug(">>> %s" % command) + self._telnet_write(telnet, "%s\n" % command) + result = self._telnet_read_until(telnet, "OK", 10) + _log_debug("<<< %s" % result) + return result + + def _verify_emulator(self): + telnet_ok = False + tn = None + while not telnet_ok: + try: + tn = telnetlib.Telnet("localhost", 5554, 10) + if tn is not None: + self._telnet_read_until(tn, "OK", 10) + self._telnet_cmd(tn, "avd status") + self._telnet_cmd(tn, "redir list") + self._telnet_cmd(tn, "network status") + self._telnet_write(tn, "quit\n") + tn.read_all() + telnet_ok = True + else: + _log_warning("Unable to connect to port 5554") + except Exception: + _log_warning("Trying again after unexpected exception") + finally: + if tn is not None: + tn.close() + if not telnet_ok: + time.sleep(10) + if self.proc.poll() is not None: + _log_warning("Emulator has already completed!") + return False + return telnet_ok + + def _get_avd_type(self, requested): + if requested in AVD_DICT.keys(): + return requested + if self.substs: + target_cpu = self.substs["TARGET_CPU"] + if target_cpu == "aarch64": + return "arm64" + elif target_cpu.startswith("arm"): + return "arm" + return "x86_64" + + +def _find_sdk_exe(substs, exe, tools): + if tools: + subdirs = ["emulator", "tools"] + else: + subdirs = ["platform-tools"] + + found = False + if not found and substs: + # It's best to use the tool specified by the build, rather + # than something we find on the PATH or crawl for. + try: + exe_path = substs[exe.upper()] + if os.path.exists(exe_path): + found = True + else: + _log_debug("Unable to find executable at %s" % exe_path) + except KeyError: + _log_debug("%s not set" % exe.upper()) + + # Append '.exe' to the name on Windows if it's not present, + # so that the executable can be found. + if os.name == "nt" and not exe.lower().endswith(".exe"): + exe += ".exe" + + if not found: + # Can exe be found in the Android SDK? + try: + android_sdk_root = os.environ["ANDROID_SDK_ROOT"] + for subdir in subdirs: + exe_path = os.path.join(android_sdk_root, subdir, exe) + if os.path.exists(exe_path): + found = True + break + else: + _log_debug("Unable to find executable at %s" % exe_path) + except KeyError: + _log_debug("ANDROID_SDK_ROOT not set") + + if not found: + # Can exe be found in the default bootstrap location? + for subdir in subdirs: + exe_path = os.path.join(MOZBUILD_PATH, "android-sdk-linux", subdir, exe) + if os.path.exists(exe_path): + found = True + break + else: + _log_debug("Unable to find executable at %s" % exe_path) + + if not found: + # Is exe on PATH? + exe_path = find_executable(exe) + if exe_path: + found = True + else: + _log_debug("Unable to find executable on PATH") + + if found: + _log_debug("%s found at %s" % (exe, exe_path)) + try: + creation_time = os.path.getctime(exe_path) + _log_debug(" ...with creation time %s" % time.ctime(creation_time)) + except Exception: + _log_warning("Could not get creation time for %s" % exe_path) + + prop_path = os.path.join(os.path.dirname(exe_path), "source.properties") + if os.path.exists(prop_path): + with open(prop_path, "r") as f: + for line in f.readlines(): + if line.startswith("Pkg.Revision"): + line = line.strip() + _log_debug( + " ...with SDK version in %s: %s" % (prop_path, line) + ) + break + else: + exe_path = None + return exe_path + + +def _log_debug(text): + if verbose_logging: + print("DEBUG: %s" % text) + + +def _log_warning(text): + print("WARNING: %s" % text) + + +def _log_info(text): + print("%s" % text) + + +def _download_file(url, filename, path): + _log_debug("Download %s to %s/%s..." % (url, path, filename)) + f = urllib.request.urlopen(url) + if not os.path.isdir(path): + try: + os.makedirs(path) + except Exception as e: + _log_warning(str(e)) + return False + local_file = open(os.path.join(path, filename), "wb") + local_file.write(f.read()) + local_file.close() + _log_debug("Downloaded %s to %s/%s" % (url, path, filename)) + return True + + +def _get_tooltool_manifest(substs, src_path, dst_path, filename): + if not os.path.isdir(dst_path): + try: + os.makedirs(dst_path) + except Exception as e: + _log_warning(str(e)) + copied = False + if substs and "top_srcdir" in substs: + src = os.path.join(substs["top_srcdir"], src_path) + if os.path.exists(src): + dst = os.path.join(dst_path, filename) + shutil.copy(src, dst) + copied = True + _log_debug("Copied tooltool manifest %s to %s" % (src, dst)) + if not copied: + url = os.path.join(TRY_URL, src_path) + _download_file(url, filename, dst_path) + + +def _tooltool_fetch(substs): + tooltool_full_path = os.path.join(substs["top_srcdir"], TOOLTOOL_PATH) + command = [ + sys.executable, + tooltool_full_path, + "fetch", + "-o", + "-m", + "releng.manifest", + ] + try: + response = subprocess.check_output(command, cwd=EMULATOR_HOME_DIR) + _log_debug(response) + except Exception as e: + _log_warning(str(e)) + + +def _get_host_platform(): + plat = None + if "darwin" in str(sys.platform).lower(): + plat = "macosx64" + elif "win32" in str(sys.platform).lower(): + plat = "win32" + elif "linux" in str(sys.platform).lower(): + if "64" in platform.architecture()[0]: + plat = "linux64" + else: + plat = "linux32" + return plat + + +def _verify_kvm(substs): + # 'emulator -accel-check' should produce output like: + # accel: + # 0 + # KVM (version 12) is installed and usable + # accel + emulator_path = _find_sdk_exe(substs, "emulator", True) + if not emulator_path: + emulator_path = "emulator" + command = [emulator_path, "-accel-check"] + try: + out = subprocess.check_output(command) + if six.PY3 and not isinstance(out, six.text_type): + out = out.decode("utf-8") + if "is installed and usable" in "".join(out): + return + except Exception as e: + _log_warning(str(e)) + _log_warning("Unable to verify kvm acceleration!") + _log_warning("The x86/x86_64 emulator may fail to start without kvm.") diff --git a/testing/mozbase/mozrunner/mozrunner/devices/base.py b/testing/mozbase/mozrunner/mozrunner/devices/base.py new file mode 100644 index 0000000000..d1fa660052 --- /dev/null +++ b/testing/mozbase/mozrunner/mozrunner/devices/base.py @@ -0,0 +1,256 @@ +# 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/. + +import datetime +import os +import posixpath +import shutil +import tempfile +import time + +from mozdevice import ADBError, ADBHost +from six.moves.configparser import ConfigParser, RawConfigParser + + +class Device(object): + connected = False + + def __init__(self, app_ctx, logdir=None, serial=None, restore=True): + self.app_ctx = app_ctx + self.device = self.app_ctx.device + self.restore = restore + self.serial = serial + self.logdir = os.path.abspath(os.path.expanduser(logdir)) + self.added_files = set() + self.backup_files = set() + + @property + def remote_profiles(self): + """ + A list of remote profiles on the device. + """ + remote_ini = self.app_ctx.remote_profiles_ini + if not self.device.is_file(remote_ini): + raise IOError("Remote file '%s' not found" % remote_ini) + + local_ini = tempfile.NamedTemporaryFile() + self.device.pull(remote_ini, local_ini.name) + cfg = ConfigParser() + cfg.read(local_ini.name) + + profiles = [] + for section in cfg.sections(): + if cfg.has_option(section, "Path"): + if cfg.has_option(section, "IsRelative") and cfg.getint( + section, "IsRelative" + ): + profiles.append( + posixpath.join( + posixpath.dirname(remote_ini), cfg.get(section, "Path") + ) + ) + else: + profiles.append(cfg.get(section, "Path")) + return profiles + + def pull_minidumps(self): + """ + Saves any minidumps found in the remote profile on the local filesystem. + + :returns: Path to directory containing the dumps. + """ + remote_dump_dir = posixpath.join(self.app_ctx.remote_profile, "minidumps") + local_dump_dir = tempfile.mkdtemp() + try: + self.device.pull(remote_dump_dir, local_dump_dir) + except ADBError as e: + # OK if directory not present -- sometimes called before browser start + if "does not exist" not in str(e): + try: + shutil.rmtree(local_dump_dir) + except Exception: + pass + finally: + raise e + else: + print("WARNING: {}".format(e)) + if os.listdir(local_dump_dir): + self.device.rm(remote_dump_dir, recursive=True) + self.device.mkdir(remote_dump_dir, parents=True) + return local_dump_dir + + def setup_profile(self, profile): + """ + Copy profile to the device and update the remote profiles.ini + to point to the new profile. + + :param profile: mozprofile object to copy over. + """ + if self.device.is_dir(self.app_ctx.remote_profile): + self.device.rm(self.app_ctx.remote_profile, recursive=True) + + self.device.push(profile.profile, self.app_ctx.remote_profile) + + timeout = 5 # seconds + starttime = datetime.datetime.now() + while datetime.datetime.now() - starttime < datetime.timedelta(seconds=timeout): + if self.device.is_file(self.app_ctx.remote_profiles_ini): + break + time.sleep(1) + local_profiles_ini = tempfile.NamedTemporaryFile() + if not self.device.is_file(self.app_ctx.remote_profiles_ini): + # Unless fennec is already running, and/or remote_profiles_ini is + # not inside the remote_profile (deleted above), this is entirely + # normal. + print("timed out waiting for profiles.ini") + else: + self.device.pull(self.app_ctx.remote_profiles_ini, local_profiles_ini.name) + + config = ProfileConfigParser() + config.read(local_profiles_ini.name) + for section in config.sections(): + if "Profile" in section: + config.set(section, "IsRelative", 0) + config.set(section, "Path", self.app_ctx.remote_profile) + + new_profiles_ini = tempfile.NamedTemporaryFile() + config.write(open(new_profiles_ini.name, "w")) + + self.backup_file(self.app_ctx.remote_profiles_ini) + self.device.push(new_profiles_ini.name, self.app_ctx.remote_profiles_ini) + + # Ideally all applications would read the profile the same way, but in practice + # this isn't true. Perform application specific profile-related setup if necessary. + if hasattr(self.app_ctx, "setup_profile"): + for remote_path in self.app_ctx.remote_backup_files: + self.backup_file(remote_path) + self.app_ctx.setup_profile(profile) + + def _get_online_devices(self): + adbhost = ADBHost(adb=self.app_ctx.adb) + devices = adbhost.devices() + return [ + d["device_serial"] + for d in devices + if d["state"] != "offline" + if not d["device_serial"].startswith("emulator") + ] + + def connect(self): + """ + Connects to a running device. If no serial was specified in the + constructor, defaults to the first entry in `adb devices`. + """ + if self.connected: + return + + online_devices = self._get_online_devices() + if not online_devices: + raise IOError( + "No devices connected. Ensure the device is on and " + "remote debugging via adb is enabled in the settings." + ) + self.serial = online_devices[0] + + self.connected = True + + def reboot(self): + """ + Reboots the device via adb. + """ + self.device.reboot() + + def wait_for_net(self): + active = False + time_out = 0 + while not active and time_out < 40: + if self.device.get_ip_address() is not None: + active = True + time_out += 1 + time.sleep(1) + return active + + def backup_file(self, remote_path): + if not self.restore: + return + + if self.device.exists(remote_path): + self.device.cp(remote_path, "%s.orig" % remote_path, recursive=True) + self.backup_files.add(remote_path) + else: + self.added_files.add(remote_path) + + def cleanup(self): + """ + Cleanup the device. + """ + if not self.restore: + return + + try: + self.device.remount() + # Restore the original profile + for added_file in self.added_files: + self.device.rm(added_file) + + for backup_file in self.backup_files: + if self.device.exists("%s.orig" % backup_file): + self.device.mv("%s.orig" % backup_file, backup_file) + + # Perform application specific profile cleanup if necessary + if hasattr(self.app_ctx, "cleanup_profile"): + self.app_ctx.cleanup_profile() + + # Remove the test profile + self.device.rm(self.app_ctx.remote_profile, force=True, recursive=True) + except Exception as e: + print("cleanup aborted: %s" % str(e)) + + def _rotate_log(self, srclog, index=1): + """ + Rotate a logfile, by recursively rotating logs further in the sequence, + deleting the last file if necessary. + """ + basename = os.path.basename(srclog) + basename = basename[: -len(".log")] + if index > 1: + basename = basename[: -len(".1")] + basename = "%s.%d.log" % (basename, index) + + destlog = os.path.join(self.logdir, basename) + if os.path.isfile(destlog): + if index == 3: + os.remove(destlog) + else: + self._rotate_log(destlog, index + 1) + shutil.move(srclog, destlog) + + +class ProfileConfigParser(RawConfigParser): + """ + Class to create profiles.ini config files + + Subclass of RawConfigParser that outputs .ini files in the exact + format expected for profiles.ini, which is slightly different + than the default format. + """ + + def optionxform(self, optionstr): + return optionstr + + def write(self, fp): + if self._defaults: + fp.write("[%s]\n" % ConfigParser.DEFAULTSECT) + for (key, value) in self._defaults.items(): + fp.write("%s=%s\n" % (key, str(value).replace("\n", "\n\t"))) + fp.write("\n") + for section in self._sections: + fp.write("[%s]\n" % section) + for (key, value) in self._sections[section].items(): + if key == "__name__": + continue + if (value is not None) or (self._optcre == self.OPTCRE): + key = "=".join((key, str(value).replace("\n", "\n\t"))) + fp.write("%s\n" % (key)) + fp.write("\n") diff --git a/testing/mozbase/mozrunner/mozrunner/devices/emulator.py b/testing/mozbase/mozrunner/mozrunner/devices/emulator.py new file mode 100644 index 0000000000..4a2aa81733 --- /dev/null +++ b/testing/mozbase/mozrunner/mozrunner/devices/emulator.py @@ -0,0 +1,224 @@ +# 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/. + +import datetime +import os +import shutil +import subprocess +import tempfile +import time +from telnetlib import Telnet + +from mozdevice import ADBHost +from mozprocess import ProcessHandler + +from ..errors import TimeoutException +from .base import Device +from .emulator_battery import EmulatorBattery +from .emulator_geo import EmulatorGeo +from .emulator_screen import EmulatorScreen + + +class ArchContext(object): + def __init__(self, arch, context, binary=None, avd=None, extra_args=None): + homedir = getattr(context, "homedir", "") + kernel = os.path.join(homedir, "prebuilts", "qemu-kernel", "%s", "%s") + sysdir = os.path.join(homedir, "out", "target", "product", "%s") + self.extra_args = [] + self.binary = os.path.join(context.bindir or "", "emulator") + if arch == "x86": + self.binary = os.path.join(context.bindir or "", "emulator-x86") + self.kernel = kernel % ("x86", "kernel-qemu") + self.sysdir = sysdir % "generic_x86" + elif avd: + self.avd = avd + self.extra_args = [ + "-show-kernel", + "-debug", + "init,console,gles,memcheck,adbserver,adbclient,adb,avd_config,socket", + ] + else: + self.kernel = kernel % ("arm", "kernel-qemu-armv7") + self.sysdir = sysdir % "generic" + self.extra_args = ["-cpu", "cortex-a8"] + + if binary: + self.binary = binary + + if extra_args: + self.extra_args.extend(extra_args) + + +class SDCard(object): + def __init__(self, emulator, size): + self.emulator = emulator + self.path = self.create_sdcard(size) + + def create_sdcard(self, sdcard_size): + """ + Creates an sdcard partition in the emulator. + + :param sdcard_size: Size of partition to create, e.g '10MB'. + """ + mksdcard = self.emulator.app_ctx.which("mksdcard") + path = tempfile.mktemp(prefix="sdcard", dir=self.emulator.tmpdir) + sdargs = [mksdcard, "-l", "mySdCard", sdcard_size, path] + sd = subprocess.Popen(sdargs, stdout=subprocess.PIPE, stderr=subprocess.STDOUT) + retcode = sd.wait() + if retcode: + raise Exception( + "unable to create sdcard: exit code %d: %s" + % (retcode, sd.stdout.read()) + ) + return path + + +class BaseEmulator(Device): + port = None + proc = None + telnet = None + + def __init__(self, app_ctx, **kwargs): + self.arch = ArchContext( + kwargs.pop("arch", "arm"), + app_ctx, + binary=kwargs.pop("binary", None), + avd=kwargs.pop("avd", None), + ) + super(BaseEmulator, self).__init__(app_ctx, **kwargs) + self.tmpdir = tempfile.mkdtemp() + # These rely on telnet + self.battery = EmulatorBattery(self) + self.geo = EmulatorGeo(self) + self.screen = EmulatorScreen(self) + + @property + def args(self): + """ + Arguments to pass into the emulator binary. + """ + return [self.arch.binary] + + def start(self): + """ + Starts a new emulator. + """ + if self.proc: + return + + original_devices = set(self._get_online_devices()) + + # QEMU relies on atexit() to remove temporary files, which does not + # work since mozprocess uses SIGKILL to kill the emulator process. + # Use a customized temporary directory so we can clean it up. + os.environ["ANDROID_TMP"] = self.tmpdir + + qemu_log = None + qemu_proc_args = {} + if self.logdir: + # save output from qemu to logfile + qemu_log = os.path.join(self.logdir, "qemu.log") + if os.path.isfile(qemu_log): + self._rotate_log(qemu_log) + qemu_proc_args["logfile"] = qemu_log + else: + qemu_proc_args["processOutputLine"] = lambda line: None + self.proc = ProcessHandler(self.args, **qemu_proc_args) + self.proc.run() + + devices = set(self._get_online_devices()) + now = datetime.datetime.now() + while (devices - original_devices) == set([]): + time.sleep(1) + # Sometimes it takes more than 60s to launch emulator, so we + # increase timeout value to 180s. Please see bug 1143380. + if datetime.datetime.now() - now > datetime.timedelta(seconds=180): + raise TimeoutException("timed out waiting for emulator to start") + devices = set(self._get_online_devices()) + devices = devices - original_devices + self.serial = devices.pop() + self.connect() + + def _get_online_devices(self): + adbhost = ADBHost(adb=self.app_ctx.adb) + return [ + d["device_serial"] + for d in adbhost.devices() + if d["state"] != "offline" + if d["device_serial"].startswith("emulator") + ] + + def connect(self): + """ + Connects to a running device. If no serial was specified in the + constructor, defaults to the first entry in `adb devices`. + """ + if self.connected: + return + + super(BaseEmulator, self).connect() + self.port = int(self.serial[self.serial.rindex("-") + 1 :]) + + def cleanup(self): + """ + Cleans up and kills the emulator, if it was started by mozrunner. + """ + super(BaseEmulator, self).cleanup() + if self.proc: + self.proc.kill() + self.proc = None + self.connected = False + + # Remove temporary files + if os.path.isdir(self.tmpdir): + shutil.rmtree(self.tmpdir) + + def _get_telnet_response(self, command=None): + output = [] + assert self.telnet + if command is not None: + self.telnet.write("%s\n" % command) + while True: + line = self.telnet.read_until("\n") + output.append(line.rstrip()) + if line.startswith("OK"): + return output + elif line.startswith("KO:"): + raise Exception("bad telnet response: %s" % line) + + def _run_telnet(self, command): + if not self.telnet: + self.telnet = Telnet("localhost", self.port) + self._get_telnet_response() + return self._get_telnet_response(command) + + def __del__(self): + if self.telnet: + self.telnet.write("exit\n") + self.telnet.read_all() + + +class EmulatorAVD(BaseEmulator): + def __init__(self, app_ctx, binary, avd, port=5554, **kwargs): + super(EmulatorAVD, self).__init__(app_ctx, binary=binary, avd=avd, **kwargs) + self.port = port + + @property + def args(self): + """ + Arguments to pass into the emulator binary. + """ + qemu_args = super(EmulatorAVD, self).args + qemu_args.extend(["-avd", self.arch.avd, "-port", str(self.port)]) + qemu_args.extend(self.arch.extra_args) + return qemu_args + + def start(self): + if self.proc: + return + + env = os.environ + env["ANDROID_AVD_HOME"] = self.app_ctx.avd_home + + super(EmulatorAVD, self).start() diff --git a/testing/mozbase/mozrunner/mozrunner/devices/emulator_battery.py b/testing/mozbase/mozrunner/mozrunner/devices/emulator_battery.py new file mode 100644 index 0000000000..58d42b0a0e --- /dev/null +++ b/testing/mozbase/mozrunner/mozrunner/devices/emulator_battery.py @@ -0,0 +1,53 @@ +# 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/. + + +class EmulatorBattery(object): + def __init__(self, emulator): + self.emulator = emulator + + def get_state(self): + status = {} + state = {} + + response = self.emulator._run_telnet("power display") + for line in response: + if ":" in line: + field, value = line.split(":") + value = value.strip() + if value == "true": + value = True + elif value == "false": + value = False + elif field == "capacity": + value = float(value) + status[field] = value + + # pylint --py3k W1619 + state["level"] = status.get("capacity", 0.0) / 100 + if status.get("AC") == "online": + state["charging"] = True + else: + state["charging"] = False + + return state + + def get_charging(self): + return self.get_state()["charging"] + + def get_level(self): + return self.get_state()["level"] + + def set_level(self, level): + self.emulator._run_telnet("power capacity %d" % (level * 100)) + + def set_charging(self, charging): + if charging: + cmd = "power ac on" + else: + cmd = "power ac off" + self.emulator._run_telnet(cmd) + + charging = property(get_charging, set_charging) + level = property(get_level, set_level) diff --git a/testing/mozbase/mozrunner/mozrunner/devices/emulator_geo.py b/testing/mozbase/mozrunner/mozrunner/devices/emulator_geo.py new file mode 100644 index 0000000000..a1fd6fc8b2 --- /dev/null +++ b/testing/mozbase/mozrunner/mozrunner/devices/emulator_geo.py @@ -0,0 +1,16 @@ +# 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/. + + +class EmulatorGeo(object): + def __init__(self, emulator): + self.emulator = emulator + + def set_default_location(self): + self.lon = -122.08769 + self.lat = 37.41857 + self.set_location(self.lon, self.lat) + + def set_location(self, lon, lat): + self.emulator._run_telnet("geo fix %0.5f %0.5f" % (self.lon, self.lat)) diff --git a/testing/mozbase/mozrunner/mozrunner/devices/emulator_screen.py b/testing/mozbase/mozrunner/mozrunner/devices/emulator_screen.py new file mode 100644 index 0000000000..8f261c3610 --- /dev/null +++ b/testing/mozbase/mozrunner/mozrunner/devices/emulator_screen.py @@ -0,0 +1,91 @@ +# 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/. + + +class EmulatorScreen(object): + """Class for screen related emulator commands.""" + + SO_PORTRAIT_PRIMARY = "portrait-primary" + SO_PORTRAIT_SECONDARY = "portrait-secondary" + SO_LANDSCAPE_PRIMARY = "landscape-primary" + SO_LANDSCAPE_SECONDARY = "landscape-secondary" + + def __init__(self, emulator): + self.emulator = emulator + + def initialize(self): + self.orientation = self.SO_PORTRAIT_PRIMARY + + def _get_raw_orientation(self): + """Get the raw value of the current device orientation.""" + response = self.emulator._run_telnet("sensor get orientation") + + return response[0].split("=")[1].strip() + + def _set_raw_orientation(self, data): + """Set the raw value of the specified device orientation.""" + self.emulator._run_telnet("sensor set orientation %s" % data) + + def get_orientation(self): + """Get the current device orientation. + + Returns; + orientation -- Orientation of the device. One of: + SO_PORTRAIT_PRIMARY - system buttons at the bottom + SO_PORTRIAT_SECONDARY - system buttons at the top + SO_LANDSCAPE_PRIMARY - system buttons at the right + SO_LANDSCAPE_SECONDARY - system buttons at the left + + """ + data = self._get_raw_orientation() + + if data == "0:-90:0": + orientation = self.SO_PORTRAIT_PRIMARY + elif data == "0:90:0": + orientation = self.SO_PORTRAIT_SECONDARY + elif data == "0:0:90": + orientation = self.SO_LANDSCAPE_PRIMARY + elif data == "0:0:-90": + orientation = self.SO_LANDSCAPE_SECONDARY + else: + raise ValueError("Unknown orientation sensor value: %s." % data) + + return orientation + + def set_orientation(self, orientation): + """Set the specified device orientation. + + Args + orientation -- Orientation of the device. One of: + SO_PORTRAIT_PRIMARY - system buttons at the bottom + SO_PORTRIAT_SECONDARY - system buttons at the top + SO_LANDSCAPE_PRIMARY - system buttons at the right + SO_LANDSCAPE_SECONDARY - system buttons at the left + """ + orientation = SCREEN_ORIENTATIONS[orientation] + + if orientation == self.SO_PORTRAIT_PRIMARY: + data = "0:-90:0" + elif orientation == self.SO_PORTRAIT_SECONDARY: + data = "0:90:0" + elif orientation == self.SO_LANDSCAPE_PRIMARY: + data = "0:0:90" + elif orientation == self.SO_LANDSCAPE_SECONDARY: + data = "0:0:-90" + else: + raise ValueError("Invalid orientation: %s" % orientation) + + self._set_raw_orientation(data) + + orientation = property(get_orientation, set_orientation) + + +SCREEN_ORIENTATIONS = { + "portrait": EmulatorScreen.SO_PORTRAIT_PRIMARY, + "landscape": EmulatorScreen.SO_LANDSCAPE_PRIMARY, + "portrait-primary": EmulatorScreen.SO_PORTRAIT_PRIMARY, + "landscape-primary": EmulatorScreen.SO_LANDSCAPE_PRIMARY, + "portrait-secondary": EmulatorScreen.SO_PORTRAIT_SECONDARY, + "landscape-secondary": EmulatorScreen.SO_LANDSCAPE_SECONDARY, +} diff --git a/testing/mozbase/mozrunner/mozrunner/errors.py b/testing/mozbase/mozrunner/mozrunner/errors.py new file mode 100644 index 0000000000..2c4ea50d5d --- /dev/null +++ b/testing/mozbase/mozrunner/mozrunner/errors.py @@ -0,0 +1,16 @@ +#!/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/. + + +class RunnerException(Exception): + """Base exception handler for mozrunner related errors""" + + +class RunnerNotStartedError(RunnerException): + """Exception handler in case the runner hasn't been started""" + + +class TimeoutException(RunnerException): + """Raised on timeout waiting for targets to start.""" diff --git a/testing/mozbase/mozrunner/mozrunner/runners.py b/testing/mozbase/mozrunner/mozrunner/runners.py new file mode 100644 index 0000000000..75fbf60733 --- /dev/null +++ b/testing/mozbase/mozrunner/mozrunner/runners.py @@ -0,0 +1,144 @@ +# 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/. + + +""" +This module contains a set of shortcut methods that create runners for commonly +used Mozilla applications, such as Firefox, Firefox for Android or Thunderbird. +""" + +from .application import get_app_context +from .base import BlinkRuntimeRunner, FennecRunner, GeckoRuntimeRunner +from .devices import EmulatorAVD + + +def Runner(*args, **kwargs): + """ + Create a generic GeckoRuntime runner. + + :param binary: Path to binary. + :param cmdargs: Arguments to pass into binary. + :param profile: Profile object to use. + :param env: Environment variables to pass into the gecko process. + :param clean_profile: If True, restores profile back to original state. + :param process_class: Class used to launch the binary. + :param process_args: Arguments to pass into process_class. + :param symbols_path: Path to symbol files used for crash analysis. + :param show_crash_reporter: allow the crash reporter window to pop up. + Defaults to False. + :returns: A generic GeckoRuntimeRunner. + """ + return GeckoRuntimeRunner(*args, **kwargs) + + +def FirefoxRunner(*args, **kwargs): + """ + Create a desktop Firefox runner. + + :param binary: Path to Firefox binary. + :param cmdargs: Arguments to pass into binary. + :param profile: Profile object to use. + :param env: Environment variables to pass into the gecko process. + :param clean_profile: If True, restores profile back to original state. + :param process_class: Class used to launch the binary. + :param process_args: Arguments to pass into process_class. + :param symbols_path: Path to symbol files used for crash analysis. + :param show_crash_reporter: allow the crash reporter window to pop up. + Defaults to False. + :returns: A GeckoRuntimeRunner for Firefox. + """ + kwargs["app_ctx"] = get_app_context("firefox")() + return GeckoRuntimeRunner(*args, **kwargs) + + +def ThunderbirdRunner(*args, **kwargs): + """ + Create a desktop Thunderbird runner. + + :param binary: Path to Thunderbird binary. + :param cmdargs: Arguments to pass into binary. + :param profile: Profile object to use. + :param env: Environment variables to pass into the gecko process. + :param clean_profile: If True, restores profile back to original state. + :param process_class: Class used to launch the binary. + :param process_args: Arguments to pass into process_class. + :param symbols_path: Path to symbol files used for crash analysis. + :param show_crash_reporter: allow the crash reporter window to pop up. + Defaults to False. + :returns: A GeckoRuntimeRunner for Thunderbird. + """ + kwargs["app_ctx"] = get_app_context("thunderbird")() + return GeckoRuntimeRunner(*args, **kwargs) + + +def ChromeRunner(*args, **kwargs): + """ + Create a desktop Google Chrome runner. + + :param binary: Path to Chrome binary. + :param cmdargs: Arguments to pass into the binary. + """ + kwargs["app_ctx"] = get_app_context("chrome")() + return BlinkRuntimeRunner(*args, **kwargs) + + +def ChromiumRunner(*args, **kwargs): + """ + Create a desktop Google Chromium runner. + + :param binary: Path to Chromium binary. + :param cmdargs: Arguments to pass into the binary. + """ + kwargs["app_ctx"] = get_app_context("chromium")() + return BlinkRuntimeRunner(*args, **kwargs) + + +def FennecEmulatorRunner( + avd="mozemulator-arm", + adb_path=None, + avd_home=None, + logdir=None, + serial=None, + binary=None, + app="org.mozilla.fennec", + **kwargs +): + """ + Create a Fennec emulator runner. This can either start a new emulator + (which will use an avd), or connect to an already-running emulator. + + :param avd: name of an AVD available in your environment. + Typically obtained via tooltool: either 'mozemulator-4.3' or 'mozemulator-x86'. + Defaults to 'mozemulator-4.3' + :param avd_home: Path to avd parent directory + :param logdir: Path to save logfiles such as qemu output. + :param serial: Serial of emulator to connect to as seen in `adb devices`. + Defaults to the first entry in `adb devices`. + :param binary: Path to emulator binary. + Defaults to None, which causes the device_class to guess based on PATH. + :param app: Name of Fennec app (often org.mozilla.fennec_$USER) + Defaults to 'org.mozilla.fennec' + :param cmdargs: Arguments to pass into binary. + :returns: A DeviceRunner for Android emulators. + """ + kwargs["app_ctx"] = get_app_context("fennec")( + app, adb_path=adb_path, avd_home=avd_home, device_serial=serial + ) + device_args = { + "app_ctx": kwargs["app_ctx"], + "avd": avd, + "binary": binary, + "logdir": logdir, + } + return FennecRunner(device_class=EmulatorAVD, device_args=device_args, **kwargs) + + +runners = { + "chrome": ChromeRunner, + "chromium": ChromiumRunner, + "default": Runner, + "firefox": FirefoxRunner, + "fennec": FennecEmulatorRunner, + "thunderbird": ThunderbirdRunner, +} diff --git a/testing/mozbase/mozrunner/mozrunner/utils.py b/testing/mozbase/mozrunner/mozrunner/utils.py new file mode 100755 index 0000000000..4b23eb7e02 --- /dev/null +++ b/testing/mozbase/mozrunner/mozrunner/utils.py @@ -0,0 +1,296 @@ +#!/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/. + +"""Utility functions for mozrunner""" + +import os +import sys + +import mozinfo + +__all__ = ["findInPath", "get_metadata_from_egg"] + + +# python package method metadata by introspection +try: + import pkg_resources + + def get_metadata_from_egg(module): + ret = {} + try: + dist = pkg_resources.get_distribution(module) + except pkg_resources.DistributionNotFound: + return {} + if dist.has_metadata("PKG-INFO"): + key = None + value = "" + for line in dist.get_metadata("PKG-INFO").splitlines(): + # see http://www.python.org/dev/peps/pep-0314/ + if key == "Description": + # descriptions can be long + if not line or line[0].isspace(): + value += "\n" + line + continue + else: + key = key.strip() + value = value.strip() + ret[key] = value + + key, value = line.split(":", 1) + key = key.strip() + value = value.strip() + ret[key] = value + if dist.has_metadata("requires.txt"): + ret["Dependencies"] = "\n" + dist.get_metadata("requires.txt") + return ret + + +except ImportError: + # package resources not avaialable + def get_metadata_from_egg(module): + return {} + + +def findInPath(fileName, path=os.environ["PATH"]): + """python equivalent of which; should really be in the stdlib""" + dirs = path.split(os.pathsep) + for dir in dirs: + if os.path.isfile(os.path.join(dir, fileName)): + return os.path.join(dir, fileName) + if mozinfo.isWin: + if os.path.isfile(os.path.join(dir, fileName + ".exe")): + return os.path.join(dir, fileName + ".exe") + + +if __name__ == "__main__": + for i in sys.argv[1:]: + print(findInPath(i)) + + +def _find_marionette_in_args(*args, **kwargs): + try: + m = [a for a in args + tuple(kwargs.values()) if hasattr(a, "session")][0] + except IndexError: + print("Can only apply decorator to function using a marionette object") + raise + return m + + +def _raw_log(): + import logging + + return logging.getLogger(__name__) + + +def test_environment( + xrePath, env=None, crashreporter=True, debugger=False, useLSan=False, log=None +): + """ + populate OS environment variables for mochitest and reftests. + + Originally comes from automationutils.py. Don't use that for new code. + """ + + env = os.environ.copy() if env is None else env + log = log or _raw_log() + + assert os.path.isabs(xrePath) + + if mozinfo.isMac: + ldLibraryPath = os.path.join(os.path.dirname(xrePath), "MacOS") + else: + ldLibraryPath = xrePath + + envVar = None + if mozinfo.isUnix: + envVar = "LD_LIBRARY_PATH" + elif mozinfo.isMac: + envVar = "DYLD_LIBRARY_PATH" + elif mozinfo.isWin: + envVar = "PATH" + if envVar: + envValue = ( + (env.get(envVar), str(ldLibraryPath)) + if mozinfo.isWin + else (ldLibraryPath, env.get(envVar)) + ) + env[envVar] = os.path.pathsep.join([path for path in envValue if path]) + + # crashreporter + env["GNOME_DISABLE_CRASH_DIALOG"] = "1" + env["XRE_NO_WINDOWS_CRASH_DIALOG"] = "1" + + if crashreporter and not debugger: + env["MOZ_CRASHREPORTER_NO_REPORT"] = "1" + env["MOZ_CRASHREPORTER"] = "1" + env["MOZ_CRASHREPORTER_SHUTDOWN"] = "1" + else: + env["MOZ_CRASHREPORTER_DISABLE"] = "1" + + # Crash on non-local network connections by default. + # MOZ_DISABLE_NONLOCAL_CONNECTIONS can be set to "0" to temporarily + # enable non-local connections for the purposes of local testing. Don't + # override the user's choice here. See bug 1049688. + env.setdefault("MOZ_DISABLE_NONLOCAL_CONNECTIONS", "1") + + # Set WebRTC logging in case it is not set yet + env.setdefault("MOZ_LOG", "signaling:3,mtransport:4,DataChannel:4,jsep:4") + env.setdefault("R_LOG_LEVEL", "6") + env.setdefault("R_LOG_DESTINATION", "stderr") + env.setdefault("R_LOG_VERBOSE", "1") + + # Ask NSS to use lower-security password encryption. See Bug 1594559 + env.setdefault("NSS_MAX_MP_PBE_ITERATION_COUNT", "10") + + # ASan specific environment stuff + if "ASAN_SYMBOLIZER_PATH" in env and os.path.isfile(env["ASAN_SYMBOLIZER_PATH"]): + llvmsym = env["ASAN_SYMBOLIZER_PATH"] + else: + if mozinfo.isMac: + llvmSymbolizerDir = ldLibraryPath + else: + llvmSymbolizerDir = xrePath + llvmsym = os.path.join( + llvmSymbolizerDir, "llvm-symbolizer" + mozinfo.info["bin_suffix"] + ) + asan = bool(mozinfo.info.get("asan")) + if asan: + try: + # Symbolizer support + if os.path.isfile(llvmsym): + env["ASAN_SYMBOLIZER_PATH"] = llvmsym + log.info("INFO | runtests.py | ASan using symbolizer at %s" % llvmsym) + else: + log.error( + "TEST-UNEXPECTED-FAIL | runtests.py | Failed to find" + " ASan symbolizer at %s" % llvmsym + ) + + # Returns total system memory in kilobytes. + if mozinfo.isWin: + # pylint --py3k W1619 + totalMemory = ( + int( + os.popen( + "wmic computersystem get TotalPhysicalMemory" + ).readlines()[1] + ) + / 1024 + ) + elif mozinfo.isMac: + # pylint --py3k W1619 + totalMemory = ( + int(os.popen("sysctl hw.memsize").readlines()[0].split()[1]) / 1024 + ) + else: + totalMemory = int(os.popen("free").readlines()[1].split()[1]) + + # Only 4 GB RAM or less available? Use custom ASan options to reduce + # the amount of resources required to do the tests. Standard options + # will otherwise lead to OOM conditions on the current test machines. + message = "INFO | runtests.py | ASan running in %s configuration" + asanOptions = [] + if totalMemory <= 1024 * 1024 * 4: + message = message % "low-memory" + asanOptions = ["quarantine_size=50331648", "malloc_context_size=5"] + else: + message = message % "default memory" + + if useLSan: + log.info("LSan enabled.") + asanOptions.append("detect_leaks=1") + lsanOptions = ["exitcode=0"] + # Uncomment out the next line to report the addresses of leaked objects. + # lsanOptions.append("report_objects=1") + env["LSAN_OPTIONS"] = ":".join(lsanOptions) + + if len(asanOptions): + env["ASAN_OPTIONS"] = ":".join(asanOptions) + + except OSError as err: + log.info( + "Failed determine available memory, disabling ASan" + " low-memory configuration: %s" % err.strerror + ) + except Exception: + log.info( + "Failed determine available memory, disabling ASan" + " low-memory configuration" + ) + else: + log.info(message) + + tsan = bool(mozinfo.info.get("tsan")) + if tsan and mozinfo.isLinux: + # Symbolizer support. + if os.path.isfile(llvmsym): + env["TSAN_OPTIONS"] = "external_symbolizer_path=%s" % llvmsym + log.info("INFO | runtests.py | TSan using symbolizer at %s" % llvmsym) + else: + log.error( + "TEST-UNEXPECTED-FAIL | runtests.py | Failed to find TSan" + " symbolizer at %s" % llvmsym + ) + + ubsan = bool(mozinfo.info.get("ubsan")) + if ubsan and (mozinfo.isLinux or mozinfo.isMac): + log.info("UBSan enabled.") + + return env + + +def get_stack_fixer_function(utilityPath, symbolsPath, hideErrors=False): + """ + Return a stack fixing function, if possible, to use on output lines. + + A stack fixing function checks if a line conforms to the output from + MozFormatCodeAddressDetails. If the line does not, the line is returned + unchanged. If the line does, an attempt is made to convert the + file+offset into something human-readable (e.g. a function name). + """ + if not mozinfo.info.get("debug"): + return None + + if os.getenv("MOZ_DISABLE_STACK_FIX", 0): + print( + "WARNING: No stack-fixing will occur because MOZ_DISABLE_STACK_FIX is set" + ) + return None + + def import_stack_fixer_module(module_name): + sys.path.insert(0, utilityPath) + module = __import__(module_name, globals(), locals(), []) + sys.path.pop(0) + return module + + if symbolsPath and os.path.exists(symbolsPath): + # Run each line through fix_stacks.py, using breakpad symbol files. + # This method is preferred for automation, since native symbols may + # have been stripped. + stack_fixer_module = import_stack_fixer_module("fix_stacks") + + def stack_fixer_function(line): + return stack_fixer_module.fixSymbols( + line, + slowWarning=True, + breakpadSymsDir=symbolsPath, + hide_errors=hideErrors, + ) + + elif mozinfo.isLinux or mozinfo.isMac or mozinfo.isWin: + # Run each line through fix_stacks.py. This method is preferred for + # developer machines, so we don't have to run "mach buildsymbols". + stack_fixer_module = import_stack_fixer_module("fix_stacks") + + def stack_fixer_function(line): + return stack_fixer_module.fixSymbols( + line, slowWarning=True, hide_errors=hideErrors + ) + + else: + return None + + return stack_fixer_function diff --git a/testing/mozbase/mozrunner/setup.cfg b/testing/mozbase/mozrunner/setup.cfg new file mode 100644 index 0000000000..2a9acf13da --- /dev/null +++ b/testing/mozbase/mozrunner/setup.cfg @@ -0,0 +1,2 @@ +[bdist_wheel] +universal = 1 diff --git a/testing/mozbase/mozrunner/setup.py b/testing/mozbase/mozrunner/setup.py new file mode 100644 index 0000000000..5723a571a3 --- /dev/null +++ b/testing/mozbase/mozrunner/setup.py @@ -0,0 +1,53 @@ +# 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 setuptools import find_packages, setup + +PACKAGE_NAME = "mozrunner" +PACKAGE_VERSION = "8.2.1" + +desc = """Reliable start/stop/configuration of Mozilla Applications (Firefox, Thunderbird, etc.)""" + +deps = [ + "mozdevice>=4.0.0,<5", + "mozfile>=1.2", + "mozinfo>=0.7,<2", + "mozlog>=6.0", + "mozprocess>=1.3.0,<2", + "mozprofile~=2.3", + "six>=1.13.0,<2", +] + +EXTRAS_REQUIRE = {"crash": ["mozcrash >= 2.0"]} + +setup( + name=PACKAGE_NAME, + version=PACKAGE_VERSION, + description=desc, + long_description="see https://firefox-source-docs.mozilla.org/mozbase/index.html", + classifiers=[ + "Environment :: Console", + "Intended Audience :: Developers", + "License :: OSI Approved :: Mozilla Public License 2.0 (MPL 2.0)", + "Natural Language :: English", + "Operating System :: OS Independent", + "Programming Language :: Python :: 2.7", + "Programming Language :: Python :: 3.5", + "Topic :: Software Development :: Libraries :: Python Modules", + ], + keywords="mozilla", + author="Mozilla Automation and Tools team", + author_email="tools@lists.mozilla.org", + url="https://wiki.mozilla.org/Auto-tools/Projects/Mozbase", + license="MPL 2.0", + packages=find_packages(), + zip_safe=False, + install_requires=deps, + extras_require=EXTRAS_REQUIRE, + entry_points=""" + # -*- Entry points: -*- + [console_scripts] + mozrunner = mozrunner:cli + """, +) diff --git a/testing/mozbase/mozrunner/tests/conftest.py b/testing/mozbase/mozrunner/tests/conftest.py new file mode 100644 index 0000000000..991fc376fb --- /dev/null +++ b/testing/mozbase/mozrunner/tests/conftest.py @@ -0,0 +1,81 @@ +# 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/. + +import os +import threading +from time import sleep + +import mozrunner +import pytest +from moztest.selftest import fixtures + + +@pytest.fixture(scope="session") +def get_binary(): + if "BROWSER_PATH" in os.environ: + os.environ["GECKO_BINARY_PATH"] = os.environ["BROWSER_PATH"] + + def inner(app): + if app not in ("chrome", "chromium", "firefox"): + pytest.xfail(reason="{} support not implemented".format(app)) + + if app == "firefox": + binary = fixtures.binary() + elif app == "chrome": + binary = os.environ.get("CHROME_BINARY_PATH") + elif app == "chromium": + binary = os.environ.get("CHROMIUM_BINARY_PATH") + + if not binary: + pytest.skip("could not find a {} binary".format(app)) + return binary + + return inner + + +@pytest.fixture(params=["firefox", "chrome", "chromium"]) +def runner(request, get_binary): + app = request.param + binary = get_binary(app) + + cmdargs = ["--headless"] + if app in ["chrome", "chromium"]: + # prevents headless chromium from exiting after loading the page + cmdargs.append("--remote-debugging-port=9222") + # only needed on Windows, but no harm in specifying it everywhere + cmdargs.append("--disable-gpu") + runner = mozrunner.runners[app](binary, cmdargs=cmdargs) + runner.app = app + yield runner + runner.stop() + + +class RunnerThread(threading.Thread): + def __init__(self, runner, start=False, timeout=1): + threading.Thread.__init__(self) + self.runner = runner + self.timeout = timeout + self.do_start = start + + def run(self): + sleep(self.timeout) + if self.do_start: + self.runner.start() + else: + self.runner.stop() + + +@pytest.fixture +def create_thread(): + threads = [] + + def inner(*args, **kwargs): + thread = RunnerThread(*args, **kwargs) + threads.append(thread) + return thread + + yield inner + + for thread in threads: + thread.join() diff --git a/testing/mozbase/mozrunner/tests/manifest.ini b/testing/mozbase/mozrunner/tests/manifest.ini new file mode 100644 index 0000000000..7348004fdf --- /dev/null +++ b/testing/mozbase/mozrunner/tests/manifest.ini @@ -0,0 +1,12 @@ +[DEFAULT] +subsuite = mozbase +# We skip these tests in automated Windows builds because they trigger crashes +# in sh.exe; see bug 1489277. +skip-if = automation && os == 'win' +[test_crash.py] +[test_interactive.py] +[test_start.py] +[test_states.py] +[test_stop.py] +[test_threads.py] +[test_wait.py] diff --git a/testing/mozbase/mozrunner/tests/test_crash.py b/testing/mozbase/mozrunner/tests/test_crash.py new file mode 100644 index 0000000000..820851aa1c --- /dev/null +++ b/testing/mozbase/mozrunner/tests/test_crash.py @@ -0,0 +1,36 @@ +#!/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 unittest.mock import patch + +import mozunit +import pytest + + +@pytest.mark.parametrize("logger", [True, False]) +def test_crash_count_with_or_without_logger(runner, logger): + if runner.app == "chrome": + pytest.xfail("crash checking not implemented for ChromeRunner") + + if not logger: + runner.logger = None + fn = "check_for_crashes" + else: + fn = "log_crashes" + + with patch("mozcrash.{}".format(fn), return_value=2) as mock: + assert runner.crashed == 0 + assert runner.check_for_crashes() == 2 + assert runner.crashed == 2 + assert runner.check_for_crashes() == 2 + assert runner.crashed == 4 + + mock.return_value = 0 + assert runner.check_for_crashes() == 0 + assert runner.crashed == 4 + + +if __name__ == "__main__": + mozunit.main() diff --git a/testing/mozbase/mozrunner/tests/test_interactive.py b/testing/mozbase/mozrunner/tests/test_interactive.py new file mode 100644 index 0000000000..ab700d334c --- /dev/null +++ b/testing/mozbase/mozrunner/tests/test_interactive.py @@ -0,0 +1,40 @@ +#!/usr/bin/env python + +from time import sleep + +import mozunit + + +def test_run_interactive(runner, create_thread): + """Bug 965183: Run process in interactive mode and call wait()""" + runner.start(interactive=True) + + thread = create_thread(runner, timeout=2) + thread.start() + + # This is a blocking call. So the process should be killed by the thread + runner.wait() + thread.join() + assert not runner.is_running() + + +def test_stop_interactive(runner): + """Bug 965183: Explicitely stop process in interactive mode""" + runner.start(interactive=True) + runner.stop() + + +def test_wait_after_process_finished(runner): + """Wait after the process has been stopped should not raise an error""" + runner.start(interactive=True) + sleep(1) + runner.process_handler.kill() + + returncode = runner.wait(1) + + assert returncode not in [None, 0] + assert runner.process_handler is not None + + +if __name__ == "__main__": + mozunit.main() diff --git a/testing/mozbase/mozrunner/tests/test_start.py b/testing/mozbase/mozrunner/tests/test_start.py new file mode 100644 index 0000000000..56e01ae84d --- /dev/null +++ b/testing/mozbase/mozrunner/tests/test_start.py @@ -0,0 +1,61 @@ +#!/usr/bin/env python + +from time import sleep +from unittest.mock import patch + +import mozunit +from mozrunner import RunnerNotStartedError +from pytest import raises + + +def test_start_process(runner): + """Start the process and test properties""" + assert runner.process_handler is None + + runner.start() + + assert runner.is_running() + assert runner.process_handler is not None + + +def test_start_process_called_twice(runner): + """Start the process twice and test that first process is gone""" + runner.start() + # Bug 925480 + # Make a copy until mozprocess can kill a specific process + process_handler = runner.process_handler + + runner.start() + + try: + assert process_handler.wait(1) not in [None, 0] + finally: + process_handler.kill() + + +def test_start_with_timeout(runner): + """Start the process and set a timeout""" + runner.start(timeout=0.1) + sleep(1) + + assert not runner.is_running() + + +def test_start_with_outputTimeout(runner): + """Start the process and set a timeout""" + runner.start(outputTimeout=0.1) + sleep(1) + + assert not runner.is_running() + + +def test_fail_to_start(runner): + with patch("mozprocess.ProcessHandler.__init__") as ph_mock: + ph_mock.side_effect = Exception("Boom!") + with raises(RunnerNotStartedError): + runner.start(outputTimeout=0.1) + sleep(1) + + +if __name__ == "__main__": + mozunit.main() diff --git a/testing/mozbase/mozrunner/tests/test_states.py b/testing/mozbase/mozrunner/tests/test_states.py new file mode 100644 index 0000000000..b2eb3d119c --- /dev/null +++ b/testing/mozbase/mozrunner/tests/test_states.py @@ -0,0 +1,22 @@ +#!/usr/bin/env python + +import mozunit +import pytest +from mozrunner import RunnerNotStartedError + + +def test_errors_before_start(runner): + """Bug 965714: Not started errors before start() is called""" + + with pytest.raises(RunnerNotStartedError): + runner.is_running() + + with pytest.raises(RunnerNotStartedError): + runner.returncode + + with pytest.raises(RunnerNotStartedError): + runner.wait() + + +if __name__ == "__main__": + mozunit.main() diff --git a/testing/mozbase/mozrunner/tests/test_stop.py b/testing/mozbase/mozrunner/tests/test_stop.py new file mode 100644 index 0000000000..a4f2b1fadd --- /dev/null +++ b/testing/mozbase/mozrunner/tests/test_stop.py @@ -0,0 +1,41 @@ +#!/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/. + +import signal + +import mozunit + + +def test_stop_process(runner): + """Stop the process and test properties""" + runner.start() + returncode = runner.stop() + + assert not runner.is_running() + assert returncode not in [None, 0] + assert runner.returncode == returncode + assert runner.process_handler is not None + assert runner.wait(1) == returncode + + +def test_stop_before_start(runner): + """Stop the process before it gets started should not raise an error""" + runner.stop() + + +def test_stop_process_custom_signal(runner): + """Stop the process via a custom signal and test properties""" + runner.start() + returncode = runner.stop(signal.SIGTERM) + + assert not runner.is_running() + assert returncode not in [None, 0] + assert runner.returncode == returncode + assert runner.process_handler is not None + assert runner.wait(1) == returncode + + +if __name__ == "__main__": + mozunit.main() diff --git a/testing/mozbase/mozrunner/tests/test_threads.py b/testing/mozbase/mozrunner/tests/test_threads.py new file mode 100644 index 0000000000..fa77d92688 --- /dev/null +++ b/testing/mozbase/mozrunner/tests/test_threads.py @@ -0,0 +1,57 @@ +#!/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/. + +import mozunit + + +def test_process_start_via_thread(runner, create_thread): + """Start the runner via a thread""" + thread = create_thread(runner, True, 2) + + thread.start() + thread.join() + + assert runner.is_running() + + +def test_process_stop_via_multiple_threads(runner, create_thread): + """Stop the runner via multiple threads""" + runner.start() + threads = [] + for i in range(5): + thread = create_thread(runner, False, 5) + threads.append(thread) + thread.start() + + # Wait until the process has been stopped by another thread + for thread in threads: + thread.join() + returncode = runner.wait(1) + + assert returncode not in [None, 0] + assert runner.returncode == returncode + assert runner.process_handler is not None + assert runner.wait(2) == returncode + + +def test_process_post_stop_via_thread(runner, create_thread): + """Stop the runner and try it again with a thread a bit later""" + runner.start() + thread = create_thread(runner, False, 5) + thread.start() + + # Wait a bit to start the application gets started + runner.wait(1) + returncode = runner.stop() + thread.join() + + assert returncode not in [None, 0] + assert runner.returncode == returncode + assert runner.process_handler is not None + assert runner.wait(2) == returncode + + +if __name__ == "__main__": + mozunit.main() diff --git a/testing/mozbase/mozrunner/tests/test_wait.py b/testing/mozbase/mozrunner/tests/test_wait.py new file mode 100644 index 0000000000..d7ba721b3d --- /dev/null +++ b/testing/mozbase/mozrunner/tests/test_wait.py @@ -0,0 +1,32 @@ +#!/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/. + +import mozunit + + +def test_wait_while_running(runner): + """Wait for the process while it is running""" + runner.start() + returncode = runner.wait(1) + + assert runner.is_running() + assert returncode is None + assert runner.returncode == returncode + assert runner.process_handler is not None + + +def test_wait_after_process_finished(runner): + """Bug 965714: wait() after stop should not raise an error""" + runner.start() + runner.process_handler.kill() + + returncode = runner.wait(1) + + assert returncode not in [None, 0] + assert runner.process_handler is not None + + +if __name__ == "__main__": + mozunit.main() |