summaryrefslogtreecommitdiffstats
path: root/testing/mozbase/mozrunner
diff options
context:
space:
mode:
Diffstat (limited to 'testing/mozbase/mozrunner')
-rw-r--r--testing/mozbase/mozrunner/mozrunner/__init__.py12
-rw-r--r--testing/mozbase/mozrunner/mozrunner/application.py156
-rw-r--r--testing/mozbase/mozrunner/mozrunner/base/__init__.py8
-rw-r--r--testing/mozbase/mozrunner/mozrunner/base/browser.py122
-rw-r--r--testing/mozbase/mozrunner/mozrunner/base/device.py199
-rw-r--r--testing/mozbase/mozrunner/mozrunner/base/runner.py278
-rw-r--r--testing/mozbase/mozrunner/mozrunner/cli.py181
-rw-r--r--testing/mozbase/mozrunner/mozrunner/devices/__init__.py17
-rw-r--r--testing/mozbase/mozrunner/mozrunner/devices/android_device.py1063
-rw-r--r--testing/mozbase/mozrunner/mozrunner/devices/base.py256
-rw-r--r--testing/mozbase/mozrunner/mozrunner/devices/emulator.py224
-rw-r--r--testing/mozbase/mozrunner/mozrunner/devices/emulator_battery.py53
-rw-r--r--testing/mozbase/mozrunner/mozrunner/devices/emulator_geo.py16
-rw-r--r--testing/mozbase/mozrunner/mozrunner/devices/emulator_screen.py91
-rw-r--r--testing/mozbase/mozrunner/mozrunner/errors.py16
-rw-r--r--testing/mozbase/mozrunner/mozrunner/runners.py144
-rwxr-xr-xtesting/mozbase/mozrunner/mozrunner/utils.py296
-rw-r--r--testing/mozbase/mozrunner/setup.cfg2
-rw-r--r--testing/mozbase/mozrunner/setup.py53
-rw-r--r--testing/mozbase/mozrunner/tests/conftest.py81
-rw-r--r--testing/mozbase/mozrunner/tests/manifest.ini12
-rw-r--r--testing/mozbase/mozrunner/tests/test_crash.py36
-rw-r--r--testing/mozbase/mozrunner/tests/test_interactive.py40
-rw-r--r--testing/mozbase/mozrunner/tests/test_start.py61
-rw-r--r--testing/mozbase/mozrunner/tests/test_states.py22
-rw-r--r--testing/mozbase/mozrunner/tests/test_stop.py41
-rw-r--r--testing/mozbase/mozrunner/tests/test_threads.py57
-rw-r--r--testing/mozbase/mozrunner/tests/test_wait.py32
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()