summaryrefslogtreecommitdiffstats
path: root/testing/mozbase/mozrunner
diff options
context:
space:
mode:
authorMatt A. Tobin <mattatobin@localhost.localdomain>2018-02-02 04:16:08 -0500
committerMatt A. Tobin <mattatobin@localhost.localdomain>2018-02-02 04:16:08 -0500
commit5f8de423f190bbb79a62f804151bc24824fa32d8 (patch)
tree10027f336435511475e392454359edea8e25895d /testing/mozbase/mozrunner
parent49ee0794b5d912db1f95dce6eb52d781dc210db5 (diff)
downloadUXP-5f8de423f190bbb79a62f804151bc24824fa32d8.tar
UXP-5f8de423f190bbb79a62f804151bc24824fa32d8.tar.gz
UXP-5f8de423f190bbb79a62f804151bc24824fa32d8.tar.lz
UXP-5f8de423f190bbb79a62f804151bc24824fa32d8.tar.xz
UXP-5f8de423f190bbb79a62f804151bc24824fa32d8.zip
Add m-esr52 at 52.6.0
Diffstat (limited to 'testing/mozbase/mozrunner')
-rw-r--r--testing/mozbase/mozrunner/mozrunner/__init__.py11
-rw-r--r--testing/mozbase/mozrunner/mozrunner/application.py265
-rw-r--r--testing/mozbase/mozrunner/mozrunner/base/__init__.py5
-rw-r--r--testing/mozbase/mozrunner/mozrunner/base/browser.py80
-rw-r--r--testing/mozbase/mozrunner/mozrunner/base/device.py185
-rw-r--r--testing/mozbase/mozrunner/mozrunner/base/runner.py233
-rw-r--r--testing/mozbase/mozrunner/mozrunner/cli.py152
-rw-r--r--testing/mozbase/mozrunner/mozrunner/devices/__init__.py13
-rw-r--r--testing/mozbase/mozrunner/mozrunner/devices/android_device.py773
-rw-r--r--testing/mozbase/mozrunner/mozrunner/devices/autophone.py651
-rw-r--r--testing/mozbase/mozrunner/mozrunner/devices/base.py306
-rw-r--r--testing/mozbase/mozrunner/mozrunner/devices/emulator.py288
-rw-r--r--testing/mozbase/mozrunner/mozrunner/devices/emulator_battery.py53
-rw-r--r--testing/mozbase/mozrunner/mozrunner/devices/emulator_geo.py17
-rw-r--r--testing/mozbase/mozrunner/mozrunner/devices/emulator_screen.py89
-rw-r--r--testing/mozbase/mozrunner/mozrunner/errors.py16
-rw-r--r--testing/mozbase/mozrunner/mozrunner/resources/metrotestharness.exebin0 -> 63488 bytes
-rw-r--r--testing/mozbase/mozrunner/mozrunner/runners.py211
-rwxr-xr-xtesting/mozbase/mozrunner/mozrunner/utils.py279
-rw-r--r--testing/mozbase/mozrunner/setup.py54
-rw-r--r--testing/mozbase/mozrunner/tests/manifest.ini7
-rw-r--r--testing/mozbase/mozrunner/tests/mozrunnertest.py34
-rw-r--r--testing/mozbase/mozrunner/tests/test_crash.py37
-rw-r--r--testing/mozbase/mozrunner/tests/test_interactive.py53
-rw-r--r--testing/mozbase/mozrunner/tests/test_start.py45
-rw-r--r--testing/mozbase/mozrunner/tests/test_states.py18
-rw-r--r--testing/mozbase/mozrunner/tests/test_stop.py39
-rw-r--r--testing/mozbase/mozrunner/tests/test_threads.py73
-rw-r--r--testing/mozbase/mozrunner/tests/test_wait.py29
29 files changed, 4016 insertions, 0 deletions
diff --git a/testing/mozbase/mozrunner/mozrunner/__init__.py b/testing/mozbase/mozrunner/mozrunner/__init__.py
new file mode 100644
index 000000000..0fec5c238
--- /dev/null
+++ b/testing/mozbase/mozrunner/mozrunner/__init__.py
@@ -0,0 +1,11 @@
+# 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/.
+from .cli import *
+from .errors import *
+from .runners import *
+
+import base
+import devices
+import utils
diff --git a/testing/mozbase/mozrunner/mozrunner/application.py b/testing/mozbase/mozrunner/mozrunner/application.py
new file mode 100644
index 000000000..6734487ae
--- /dev/null
+++ b/testing/mozbase/mozrunner/mozrunner/application.py
@@ -0,0 +1,265 @@
+# 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 abc import ABCMeta, abstractmethod
+from distutils.spawn import find_executable
+import glob
+import os
+import posixpath
+
+from mozdevice import DeviceManagerADB, DMError, DroidADB
+from mozprofile import (
+ Profile,
+ FirefoxProfile,
+ MetroFirefoxProfile,
+ ThunderbirdProfile
+)
+
+here = os.path.abspath(os.path.dirname(__file__))
+
+
+def get_app_context(appname):
+ context_map = {'default': DefaultContext,
+ 'b2g': B2GContext,
+ 'firefox': FirefoxContext,
+ 'thunderbird': ThunderbirdContext,
+ 'metro': MetroContext,
+ 'fennec': FennecContext}
+ if appname not in context_map:
+ raise KeyError("Application '%s' not supported!" % appname)
+ return context_map[appname]
+
+
+class DefaultContext(object):
+ profile_class = Profile
+
+
+class RemoteContext(object):
+ __metaclass__ = ABCMeta
+ _dm = None
+ _remote_profile = None
+ _adb = None
+ profile_class = Profile
+ dm_class = DeviceManagerADB
+ _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 dm(self):
+ if not self._dm:
+ self._dm = self.dm_class(adbPath=self.adb, autoconnect=False)
+ return self._dm
+
+ @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
+
+
+class FennecContext(RemoteContext):
+ _remote_profiles_ini = None
+ _remote_test_root = None
+
+ def __init__(self, app=None, adb_path=None, avd_home=None):
+ self._adb = adb_path
+ self.avd_home = avd_home
+ self.dm_class = DroidADB
+ self.remote_process = app or self.dm._packageName
+
+ def stop_application(self):
+ self.dm.stopApplication(self.remote_process)
+
+ @property
+ def remote_test_root(self):
+ if not self._remote_test_root:
+ self._remote_test_root = self.dm.getDeviceRoot()
+ return self._remote_test_root
+
+ @property
+ def remote_profiles_ini(self):
+ if not self._remote_profiles_ini:
+ self._remote_profiles_ini = posixpath.join(
+ self.dm.getAppRoot(self.remote_process),
+ 'files', 'mozilla', 'profiles.ini'
+ )
+ return self._remote_profiles_ini
+
+
+class B2GContext(RemoteContext):
+ _remote_settings_db = None
+
+ def __init__(self, b2g_home=None, adb_path=None):
+ self.homedir = b2g_home or os.environ.get('B2G_HOME')
+
+ if self.homedir is not None and not os.path.isdir(self.homedir):
+ raise OSError('Homedir \'%s\' does not exist!' % self.homedir)
+
+ self._adb = adb_path
+ self._update_tools = None
+ self._fastboot = None
+
+ self.remote_binary = '/system/bin/b2g.sh'
+ self.remote_bundles_dir = '/system/b2g/distribution/bundles'
+ self.remote_busybox = '/system/bin/busybox'
+ self.remote_process = '/system/b2g/b2g'
+ self.remote_profiles_ini = '/data/b2g/mozilla/profiles.ini'
+ self.remote_settings_json = '/system/b2g/defaults/settings.json'
+ self.remote_idb_dir = '/data/local/storage/permanent/chrome/idb'
+ self.remote_test_root = '/data/local/tests'
+ self.remote_webapps_dir = '/data/local/webapps'
+
+ self.remote_backup_files = [
+ self.remote_settings_json,
+ self.remote_webapps_dir,
+ ]
+
+ @property
+ def fastboot(self):
+ if self._fastboot is None:
+ self._fastboot = self.which('fastboot')
+ return self._fastboot
+
+ @property
+ def update_tools(self):
+ if self._update_tools is None and self.homedir is not None:
+ self._update_tools = os.path.join(self.homedir, 'tools', 'update-tools')
+ return self._update_tools
+
+ @property
+ def bindir(self):
+ if self._bindir is None and self.homedir is not None:
+ # TODO get this via build configuration
+ path = os.path.join(self.homedir, 'out', 'host', '*', 'bin')
+ paths = glob.glob(path)
+ if paths:
+ self._bindir = paths[0]
+ return self._bindir
+
+ @property
+ def remote_settings_db(self):
+ if not self._remote_settings_db:
+ for filename in self.dm.listFiles(self.remote_idb_dir):
+ if filename.endswith('ssegtnti.sqlite'):
+ self._remote_settings_db = posixpath.join(self.remote_idb_dir, filename)
+ break
+ else:
+ raise DMError("Could not find settings db in '%s'!" % self.remote_idb_dir)
+ return self._remote_settings_db
+
+ def stop_application(self):
+ self.dm.shellCheckOutput(['stop', 'b2g'])
+
+ def setup_profile(self, profile):
+ # For some reason user.js in the profile doesn't get picked up.
+ # Manually copy it over to prefs.js. See bug 1009730 for more details.
+ self.dm.moveTree(posixpath.join(self.remote_profile, 'user.js'),
+ posixpath.join(self.remote_profile, 'prefs.js'))
+
+ if self.dm.fileExists(posixpath.join(self.remote_profile, 'settings.json')):
+ # On devices, settings.json is only read from the profile if
+ # the system location doesn't exist.
+ if self.dm.fileExists(self.remote_settings_json):
+ self.dm.removeFile(self.remote_settings_json)
+
+ # Delete existing settings db and create a new empty one to force new
+ # settings to be loaded.
+ self.dm.removeFile(self.remote_settings_db)
+ self.dm.shellCheckOutput(['touch', self.remote_settings_db])
+
+ # On devices, the webapps are located in /data/local/webapps instead of the profile.
+ # In some cases we may need to replace the existing webapps, in others we may just
+ # need to leave them in the profile. If the system app is present in the profile
+ # webapps, it's a good indication that they should replace the existing ones wholesale.
+ profile_webapps = posixpath.join(self.remote_profile, 'webapps')
+ if self.dm.dirExists(posixpath.join(profile_webapps, 'system.gaiamobile.org')):
+ self.dm.removeDir(self.remote_webapps_dir)
+ self.dm.moveTree(profile_webapps, self.remote_webapps_dir)
+
+ # On devices extensions are installed in the system dir
+ extension_dir = os.path.join(profile.profile, 'extensions', 'staged')
+ if os.path.isdir(extension_dir):
+ # Copy the extensions to the B2G bundles dir.
+ for filename in os.listdir(extension_dir):
+ path = posixpath.join(self.remote_bundles_dir, filename)
+ if self.dm.fileExists(path):
+ self.dm.removeFile(path)
+ self.dm.pushDir(extension_dir, self.remote_bundles_dir)
+
+ def cleanup_profile(self):
+ # Delete any bundled extensions
+ extension_dir = posixpath.join(self.remote_profile, 'extensions', 'staged')
+ if self.dm.dirExists(extension_dir):
+ for filename in self.dm.listFiles(extension_dir):
+ try:
+ self.dm.removeDir(posixpath.join(self.remote_bundles_dir, filename))
+ except DMError:
+ pass
+
+ if self.dm.fileExists(posixpath.join(self.remote_profile, 'settings.json')):
+ # Force settings.db to be restored to defaults
+ self.dm.removeFile(self.remote_settings_db)
+ self.dm.shellCheckOutput(['touch', self.remote_settings_db])
+
+
+class FirefoxContext(object):
+ profile_class = FirefoxProfile
+
+
+class ThunderbirdContext(object):
+ profile_class = ThunderbirdProfile
+
+
+class MetroContext(object):
+ profile_class = MetroFirefoxProfile
+
+ def __init__(self, binary=None):
+ self.binary = binary or os.environ.get('BROWSER_PATH', None)
+
+ def wrap_command(self, command):
+ immersive_helper_path = os.path.join(os.path.dirname(here),
+ 'resources',
+ 'metrotestharness.exe')
+ command[:0] = [immersive_helper_path, '-firefoxpath']
+ return command
diff --git a/testing/mozbase/mozrunner/mozrunner/base/__init__.py b/testing/mozbase/mozrunner/mozrunner/base/__init__.py
new file mode 100644
index 000000000..603d08f76
--- /dev/null
+++ b/testing/mozbase/mozrunner/mozrunner/base/__init__.py
@@ -0,0 +1,5 @@
+from .runner import BaseRunner
+from .device import DeviceRunner, FennecRunner
+from .browser import GeckoRuntimeRunner
+
+__all__ = ['BaseRunner', 'DeviceRunner', 'FennecRunner', 'GeckoRuntimeRunner']
diff --git a/testing/mozbase/mozrunner/mozrunner/base/browser.py b/testing/mozbase/mozrunner/mozrunner/base/browser.py
new file mode 100644
index 000000000..998e4ccc5
--- /dev/null
+++ b/testing/mozbase/mozrunner/mozrunner/base/browser.py
@@ -0,0 +1,80 @@
+# 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 mozinfo
+import os
+import sys
+
+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 = cmdargs or []
+
+ # allows you to run an instance of Firefox separately from any other instances
+ self.env['MOZ_NO_REMOTE'] = '1'
+ # keeps Firefox attached to the terminal window after it starts
+ self.env['NO_EM_RESTART'] = '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
+
+ if hasattr(self.app_ctx, 'wrap_command'):
+ command = self.app_ctx.wrap_command(command)
+ 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)
diff --git a/testing/mozbase/mozrunner/mozrunner/base/device.py b/testing/mozbase/mozrunner/mozrunner/base/device.py
new file mode 100644
index 000000000..2252203d1
--- /dev/null
+++ b/testing/mozbase/mozrunner/mozrunner/base/device.py
@@ -0,0 +1,185 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+from __future__ import print_function
+
+import datetime
+import re
+import signal
+import sys
+import tempfile
+import time
+
+import mozfile
+
+from .runner import BaseRunner
+from ..devices import BaseEmulator
+
+
+class DeviceRunner(BaseRunner):
+ """
+ The base runner class used for running gecko on
+ remote devices (or emulators), such as B2G.
+ """
+ env = {'MOZ_CRASHREPORTER': '1',
+ 'MOZ_CRASHREPORTER_NO_REPORT': '1',
+ 'MOZ_CRASHREPORTER_SHUTDOWN': '1',
+ 'MOZ_HIDE_RESULTS_TABLE': '1',
+ 'MOZ_LOG': 'signaling:3,mtransport:4,DataChannel:4,jsep:4,MediaPipelineFactory:4',
+ 'R_LOG_LEVEL': '6',
+ 'R_LOG_DESTINATION': 'stderr',
+ 'R_LOG_VERBOSE': '1',
+ 'NO_EM_RESTART': '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)
+
+ process_args = {'stream': sys.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):
+ cmd = [self.app_ctx.adb]
+ if self.app_ctx.dm._deviceSerial:
+ cmd.extend(['-s', self.app_ctx.dm._deviceSerial])
+ cmd.append('shell')
+ for k, v in self._device_env.iteritems():
+ cmd.append('%s=%s' % (k, v))
+ cmd.append(self.app_ctx.remote_binary)
+ return cmd
+
+ 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)
+
+ # TODO: this doesn't work well when the device is running but dropped
+ # wifi for some reason. It would be good to probe the state of the device
+ # to see if we have the homescreen running, or something, before waiting here
+ self.device.wait_for_net()
+
+ if not self.device.wait_for_net():
+ raise Exception("Network did not come up when starting device")
+
+ pid = BaseRunner.start(self, *args, **kwargs)
+
+ timeout = 10 # seconds
+ starttime = datetime.datetime.now()
+ while datetime.datetime.now() - starttime < datetime.timedelta(seconds=timeout):
+ if self.is_running():
+ break
+ time.sleep(1)
+ else:
+ print("timed out waiting for '%s' process to start" % self.app_ctx.remote_process)
+
+ if not self.device.wait_for_net():
+ raise Exception("Failed to get a network connection")
+ return pid
+
+ def stop(self, sig=None):
+ def _wait_for_shutdown(pid, timeout=10):
+ start_time = datetime.datetime.now()
+ end_time = datetime.timedelta(seconds=timeout)
+ while datetime.datetime.now() - start_time < end_time:
+ if self.is_running() != pid:
+ return True
+ time.sleep(1)
+ return False
+
+ remote_pid = self.is_running()
+ if remote_pid:
+ self.app_ctx.dm.killProcess(
+ self.app_ctx.remote_process, sig=sig)
+ if not _wait_for_shutdown(remote_pid) and sig is not None:
+ print("timed out waiting for '%s' 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
+ remote_pid = self.is_running() or remote_pid
+ self.app_ctx.stop_application()
+ if not _wait_for_shutdown(remote_pid):
+ print("timed out waiting for '%s' process to exit".format(
+ self.app_ctx.remote_process))
+
+ def is_running(self):
+ return self.app_ctx.dm.processExist(self.app_ctx.remote_process)
+
+ 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 []
+
+ @property
+ def command(self):
+ cmd = [self.app_ctx.adb]
+ if self.app_ctx.dm._deviceSerial:
+ cmd.extend(["-s", self.app_ctx.dm._deviceSerial])
+ cmd.append("shell")
+ app = "%s/org.mozilla.gecko.BrowserApp" % self.app_ctx.remote_process
+ am_subcommand = ["am", "start", "-a", "android.activity.MAIN", "-n", app]
+ app_params = ["-no-remote", "-profile", self.app_ctx.remote_profile]
+ app_params.extend(self.cmdargs)
+ am_subcommand.extend(["--es", "args", "'%s'" % " ".join(app_params)])
+ # Append env variables in the form |--es env0 MOZ_CRASHREPORTER=1|
+ for (count, (k, v)) in enumerate(self._device_env.iteritems()):
+ am_subcommand.extend(["--es", "env%d" % count, "%s=%s" % (k, v)])
+ cmd.append("%s" % " ".join(am_subcommand))
+ return cmd
diff --git a/testing/mozbase/mozrunner/mozrunner/base/runner.py b/testing/mozbase/mozrunner/mozrunner/base/runner.py
new file mode 100644
index 000000000..98628f6f3
--- /dev/null
+++ b/testing/mozbase/mozrunner/mozrunner/base/runner.py
@@ -0,0 +1,233 @@
+#!/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 abc import ABCMeta, abstractproperty
+import os
+import subprocess
+import traceback
+
+from mozlog import get_default_logger
+from mozprocess import ProcessHandler
+try:
+ import mozcrash
+except ImportError:
+ mozcrash = None
+
+from ..application import DefaultContext
+from ..errors import RunnerNotStartedError
+
+
+class BaseRunner(object):
+ """
+ The base runner class for all mozrunner objects, both local and remote.
+ """
+ __metaclass__ = ABCMeta
+ 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):
+ self.app_ctx = app_ctx or DefaultContext()
+
+ if isinstance(profile, basestring):
+ 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
+
+ def __del__(self):
+ self.cleanup()
+
+ @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
+ """
+ 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))
+ if interactive:
+ self.process_handler = subprocess.Popen(cmd, env=self.env)
+ # TODO: other arguments
+ else:
+ # this run uses the managed processhandler
+ self.process_handler = self.process_class(cmd, env=self.env, **self.process_args)
+ self.process_handler.run(self.timeout, self.output_timeout)
+
+ 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):
+ """
+ Kill the process.
+
+ :param sig: Signal used to kill the process, defaults to SIGKILL
+ (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)
+
+ 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:
+ traceback.print_exc()
+
+ return crash_count
+
+ def cleanup(self):
+ """
+ Cleanup all runner state
+ """
+ self.stop()
diff --git a/testing/mozbase/mozrunner/mozrunner/cli.py b/testing/mozbase/mozrunner/mozrunner/cli.py
new file mode 100644
index 000000000..9b340edb6
--- /dev/null
+++ b/testing/mozbase/mozrunner/mozrunner/cli.py
@@ -0,0 +1,152 @@
+# 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"""
+ return 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 000000000..bdb7586c9
--- /dev/null
+++ b/testing/mozbase/mozrunner/mozrunner/devices/__init__.py
@@ -0,0 +1,13 @@
+# 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 emulator import BaseEmulator, Emulator, EmulatorAVD
+from base import Device
+
+import emulator_battery
+import emulator_geo
+import emulator_screen
+
+__all__ = ['BaseEmulator', 'Emulator', '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 000000000..0052f473c
--- /dev/null
+++ b/testing/mozbase/mozrunner/mozrunner/devices/android_device.py
@@ -0,0 +1,773 @@
+# 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 fileinput
+import glob
+import os
+import platform
+import psutil
+import shutil
+import signal
+import sys
+import telnetlib
+import time
+import urlparse
+import urllib2
+from distutils.spawn import find_executable
+
+from mozdevice import DeviceManagerADB, DMError
+from mozprocess import ProcessHandler
+
+EMULATOR_HOME_DIR = os.path.join(os.path.expanduser('~'), '.mozbuild', 'android-device')
+
+EMULATOR_AUTH_FILE = os.path.join(os.path.expanduser('~'), '.emulator_console_auth_token')
+
+TOOLTOOL_URL = 'https://raw.githubusercontent.com/mozilla/build-tooltool/master/tooltool.py'
+
+TRY_URL = 'https://hg.mozilla.org/try/raw-file/default'
+
+MANIFEST_PATH = 'testing/config/tooltool-manifests'
+
+verbose_logging = False
+
+
+class AvdInfo(object):
+ """
+ Simple class to contain an AVD description.
+ """
+
+ def __init__(self, description, name, tooltool_manifest, extra_args,
+ port):
+ self.description = description
+ self.name = name
+ self.tooltool_manifest = tooltool_manifest
+ self.extra_args = extra_args
+ self.port = port
+
+
+"""
+ 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 = {
+ '4.3': AvdInfo('Android 4.3',
+ 'mozemulator-4.3',
+ 'testing/config/tooltool-manifests/androidarm_4_3/releng.manifest',
+ ['-show-kernel', '-debug',
+ 'init,console,gles,memcheck,adbserver,adbclient,adb,avd_config,socket'],
+ 5554),
+ '6.0': AvdInfo('Android 6.0',
+ 'mozemulator-6.0',
+ 'testing/config/tooltool-manifests/androidarm_6_0/releng.manifest',
+ ['-show-kernel', '-debug',
+ 'init,console,gles,memcheck,adbserver,adbclient,adb,avd_config,socket'],
+ 5554),
+ 'x86': AvdInfo('Android 4.2 x86',
+ 'mozemulator-x86',
+ 'testing/config/tooltool-manifests/androidx86/releng.manifest',
+ ['-debug',
+ 'init,console,gles,memcheck,adbserver,adbclient,adb,avd_config,socket',
+ '-qemu', '-m', '1024', '-enable-kvm'],
+ 5554)
+}
+
+
+def verify_android_device(build_obj, install=False, xre=False, debugger=False, verbose=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 JimDB is installed;
+ if JimDB is not found, prompt to set up JimDB.
+ Returns True if the emulator was started or another device was
+ already connected.
+ """
+ device_verified = False
+ emulator = AndroidEmulator('*', substs=build_obj.substs, verbose=verbose)
+ devices = emulator.dm.devices()
+ if (len(devices) > 0) and ('device' in [d[1] for d in devices]):
+ device_verified = True
+ elif emulator.is_available():
+ response = raw_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("Fetching AVD. This may take a while...")
+ emulator.update_avd()
+ _log_info("Starting emulator running %s..." %
+ emulator.get_avd_description())
+ emulator.start()
+ emulator.wait_for_start()
+ device_verified = True
+
+ if device_verified and install:
+ # Determine if Firefox is installed on the device; if not,
+ # prompt to install. This feature allows a test command to
+ # launch an emulator, install Firefox, and proceed with testing
+ # in one operation. It is also a basic safeguard against other
+ # cases where testing is requested but Firefox installation has
+ # been forgotten.
+ # If Firefox 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 is problematic because:
+ # - it prevents testing against other builds (downloaded apk)
+ # - installation may take a couple of minutes.
+ installed = emulator.dm.shellCheckOutput(['pm', 'list',
+ 'packages', 'org.mozilla.'])
+ if 'fennec' not in installed and 'firefox' not in installed:
+ response = raw_input(
+ "It looks like Firefox is not installed on this device.\n"
+ "Install Firefox? (Y/n) ").strip()
+ if response.lower().startswith('y') or response == '':
+ _log_info("Installing Firefox. This may take a while...")
+ build_obj._run_make(directory=".", target='install',
+ ensure_exit_code=False)
+
+ 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, 'xpcshell')):
+ err = '$MOZ_HOST_BIN/xpcshell does not exist'
+ if err:
+ 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, 'xpcshell')):
+ os.environ['MOZ_HOST_BIN'] = path
+ err = None
+ break
+ if err:
+ _log_info("Host utilities not found: %s" % err)
+ response = raw_input(
+ "Download and setup your host utilities? (Y/n) ").strip()
+ if response.lower().startswith('y') or response == '':
+ _log_info("Installing host utilities. This may take a while...")
+ host_platform = _get_host_platform()
+ if host_platform:
+ path = os.path.join(MANIFEST_PATH, host_platform, 'hostutils.manifest')
+ _get_tooltool_manifest(build_obj.substs, path, EMULATOR_HOME_DIR,
+ 'releng.manifest')
+ _tooltool_fetch()
+ 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, 'xpcshell')):
+ os.environ['MOZ_HOST_BIN'] = path
+ err = None
+ break
+ if err:
+ _log_warning("Unable to install host utilities.")
+ else:
+ _log_warning(
+ "Unable to install host utilities -- your platform is not supported!")
+
+ if debugger:
+ # Optionally set up JimDB. See https://wiki.mozilla.org/Mobile/Fennec/Android/GDB.
+ build_platform = _get_device_platform(build_obj.substs)
+ jimdb_path = os.path.join(EMULATOR_HOME_DIR, 'jimdb-%s' % build_platform)
+ jimdb_utils_path = os.path.join(jimdb_path, 'utils')
+ gdb_path = os.path.join(jimdb_path, 'bin', 'gdb')
+ err = None
+ if not os.path.isdir(jimdb_path):
+ err = '%s does not exist' % jimdb_path
+ elif not os.path.isfile(gdb_path):
+ err = '%s not found' % gdb_path
+ if err:
+ _log_info("JimDB (%s) not found: %s" % (build_platform, err))
+ response = raw_input(
+ "Download and setup JimDB (%s)? (Y/n) " % build_platform).strip()
+ if response.lower().startswith('y') or response == '':
+ host_platform = _get_host_platform()
+ if host_platform:
+ _log_info(
+ "Installing JimDB (%s/%s). This may take a while..." % (host_platform,
+ build_platform))
+ path = os.path.join(MANIFEST_PATH, host_platform,
+ 'jimdb-%s.manifest' % build_platform)
+ _get_tooltool_manifest(build_obj.substs, path,
+ EMULATOR_HOME_DIR, 'releng.manifest')
+ _tooltool_fetch()
+ if os.path.isfile(gdb_path):
+ # Get JimDB utilities from git repository
+ proc = ProcessHandler(['git', 'pull'], cwd=jimdb_utils_path)
+ proc.run()
+ git_pull_complete = False
+ try:
+ proc.wait()
+ if proc.proc.returncode == 0:
+ git_pull_complete = True
+ except:
+ if proc.poll() is None:
+ proc.kill(signal.SIGTERM)
+ if not git_pull_complete:
+ _log_warning("Unable to update JimDB utils from git -- "
+ "some JimDB features may be unavailable.")
+ else:
+ _log_warning("Unable to install JimDB -- unable to fetch from tooltool.")
+ else:
+ _log_warning("Unable to install JimDB -- your platform is not supported!")
+ if os.path.isfile(gdb_path):
+ # sync gdbinit.local with build settings
+ _update_gdbinit(build_obj.substs, os.path.join(jimdb_utils_path, "gdbinit.local"))
+ # ensure JimDB is in system path, so that mozdebug can find it
+ bin_path = os.path.join(jimdb_path, 'bin')
+ os.environ['PATH'] = "%s:%s" % (bin_path, os.environ['PATH'])
+
+ return device_verified
+
+
+def run_firefox_for_android(build_obj, params):
+ """
+ Launch Firefox for Android on the connected device.
+ Optional 'params' allow parameters to be passed to Firefox.
+ """
+ adb_path = _find_sdk_exe(build_obj.substs, 'adb', False)
+ if not adb_path:
+ adb_path = 'adb'
+ dm = DeviceManagerADB(autoconnect=False, adbPath=adb_path, retryLimit=1)
+ try:
+ #
+ # Construct an adb command similar to:
+ #
+ # $ adb shell am start -a android.activity.MAIN \
+ # -n org.mozilla.fennec_$USER \
+ # -d <url param> \
+ # --es args "<params>"
+ #
+ app = "%s/org.mozilla.gecko.BrowserApp" % build_obj.substs['ANDROID_PACKAGE_NAME']
+ cmd = ['am', 'start', '-a', 'android.activity.MAIN', '-n', app]
+ if params:
+ for p in params:
+ if urlparse.urlparse(p).scheme != "":
+ cmd.extend(['-d', p])
+ params.remove(p)
+ break
+ if params:
+ cmd.extend(['--es', 'args', '"%s"' % ' '.join(params)])
+ _log_debug(cmd)
+ output = dm.shellCheckOutput(cmd, timeout=10)
+ _log_info(output)
+ except DMError:
+ _log_warning("unable to launch Firefox for Android")
+ return 1
+ return 0
+
+
+def grant_runtime_permissions(build_obj):
+ """
+ Grant required runtime permissions to the specified app
+ (typically org.mozilla.fennec_$USER).
+ """
+ app = build_obj.substs['ANDROID_PACKAGE_NAME']
+ adb_path = _find_sdk_exe(build_obj.substs, 'adb', False)
+ if not adb_path:
+ adb_path = 'adb'
+ dm = DeviceManagerADB(autoconnect=False, adbPath=adb_path, retryLimit=1)
+ dm.default_timeout = 10
+ try:
+ sdk_level = dm.shellCheckOutput(['getprop', 'ro.build.version.sdk'])
+ if sdk_level and int(sdk_level) >= 23:
+ _log_info("Granting important runtime permissions to %s" % app)
+ dm.shellCheckOutput(['pm', 'grant', app, 'android.permission.WRITE_EXTERNAL_STORAGE'])
+ dm.shellCheckOutput(['pm', 'grant', app, 'android.permission.READ_EXTERNAL_STORAGE'])
+ dm.shellCheckOutput(['pm', 'grant', app, 'android.permission.ACCESS_FINE_LOCATION'])
+ dm.shellCheckOutput(['pm', 'grant', app, 'android.permission.CAMERA'])
+ dm.shellCheckOutput(['pm', 'grant', app, 'android.permission.WRITE_CONTACTS'])
+ except DMError:
+ _log_warning("Unable to grant runtime permissions to %s" % 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():
+ warn("this may take a while...")
+ emulator.update_avd()
+ emulator.start()
+ emulator.wait_for_start()
+ emulator.wait()
+ """
+
+ def __init__(self, avd_type='4.3', 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
+ adb_path = _find_sdk_exe(substs, 'adb', False)
+ if not adb_path:
+ adb_path = 'adb'
+ self.dm = DeviceManagerADB(autoconnect=False, adbPath=adb_path, retryLimit=1,
+ deviceSerial=device_serial)
+ self.dm.default_timeout = 10
+ _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.
+ """
+ for proc in psutil.process_iter():
+ name = proc.name()
+ # On some platforms, "emulator" may start an emulator with
+ # process name "emulator64-arm" or similar.
+ if name and name.startswith('emulator'):
+ 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, force=False):
+ """
+ Determine if the AVD is already installed locally.
+ (This is usually used to determine if update_avd() is likely
+ to require a download; it is a convenient way of determining
+ whether a 'this may take a while' warning is warranted.)
+
+ Returns True if the AVD is installed.
+ """
+ avd = os.path.join(
+ EMULATOR_HOME_DIR, 'avd', self.avd_info.name + '.avd')
+ if force and os.path.exists(avd):
+ shutil.rmtree(avd)
+ if os.path.exists(avd):
+ _log_debug("AVD found at %s" % avd)
+ return True
+ return False
+
+ def update_avd(self, force=False):
+ """
+ If required, update the AVD via tooltool.
+
+ If the AVD directory is not found, or "force" is requested,
+ download the tooltool manifest associated with the AVD and then
+ invoke tooltool.py on the manifest. tooltool.py will download the
+ required archive (unless already present in the local tooltool
+ cache) and install the AVD.
+ """
+ avd = os.path.join(
+ EMULATOR_HOME_DIR, 'avd', self.avd_info.name + '.avd')
+ ini_file = os.path.join(
+ EMULATOR_HOME_DIR, 'avd', self.avd_info.name + '.ini')
+ if force and os.path.exists(avd):
+ shutil.rmtree(avd)
+ if not os.path.exists(avd):
+ if os.path.exists(ini_file):
+ os.remove(ini_file)
+ path = self.avd_info.tooltool_manifest
+ _get_tooltool_manifest(self.substs, path, EMULATOR_HOME_DIR, 'releng.manifest')
+ _tooltool_fetch()
+ self._update_avd_paths()
+
+ def start(self):
+ """
+ Launch the emulator.
+ """
+ if os.path.exists(EMULATOR_AUTH_FILE):
+ os.remove(EMULATOR_AUTH_FILE)
+ _log_debug("deleted %s" % EMULATOR_AUTH_FILE)
+ # create an empty auth file to disable emulator authentication
+ auth_file = open(EMULATOR_AUTH_FILE, 'w')
+ auth_file.close()
+
+ def outputHandler(line):
+ self.emulator_log.write("<%s>\n" % line)
+ if "Invalid value for -gpu" in line or "Invalid GPU mode" in line:
+ self.gpu = False
+ env = os.environ
+ env['ANDROID_AVD_HOME'] = os.path.join(EMULATOR_HOME_DIR, "avd")
+ command = [self.emulator_path, "-avd",
+ self.avd_info.name, "-port", "5554"]
+ if self.gpu:
+ command += ['-gpu', 'swiftshader']
+ if self.avd_info.extra_args:
+ # -enable-kvm option is not valid on OSX
+ if _get_host_platform() == 'macosx64' 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 = ProcessHandler(
+ command, storeOutput=False, processOutputLine=outputHandler,
+ env=env)
+ self.proc.run()
+ _log_debug("Emulator started with pid %d" %
+ int(self.proc.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...")
+ while(('emulator-5554', 'device') not in self.dm.devices()):
+ time.sleep(10)
+ if self.check_completed():
+ return False
+ _log_debug("Device status verified.")
+
+ _log_debug("Checking that Android has booted...")
+ complete = False
+ while(not complete):
+ output = ''
+ try:
+ output = self.dm.shellCheckOutput(
+ ['getprop', 'sys.boot_completed'], timeout=5)
+ except DMError:
+ # 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
+ return True
+
+ def check_completed(self):
+ if self.proc.proc.poll() is not None:
+ 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 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:
+ 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):
+ avd_path = os.path.join(EMULATOR_HOME_DIR, "avd")
+ ini_file = os.path.join(avd_path, "test-1.ini")
+ ini_file_new = os.path.join(avd_path, self.avd_info.name + ".ini")
+ os.rename(ini_file, ini_file_new)
+ avd_dir = os.path.join(avd_path, "test-1.avd")
+ avd_dir_new = os.path.join(avd_path, self.avd_info.name + ".avd")
+ os.rename(avd_dir, avd_dir_new)
+ self._replace_ini_contents(ini_file_new)
+
+ def _replace_ini_contents(self, path):
+ with open(path, "r") as f:
+ lines = f.readlines()
+ with open(path, "w") as f:
+ for line in lines:
+ if line.startswith('path='):
+ avd_path = os.path.join(EMULATOR_HOME_DIR, "avd")
+ f.write('path=%s/%s.avd\n' %
+ (avd_path, self.avd_info.name))
+ elif line.startswith('path.rel='):
+ f.write('path.rel=avd/%s.avd\n' % self.avd_info.name)
+ else:
+ f.write(line)
+
+ def _telnet_cmd(self, telnet, command):
+ _log_debug(">>> " + command)
+ telnet.write('%s\n' % command)
+ result = telnet.read_until('OK', 10)
+ _log_debug("<<< " + result)
+ return result
+
+ def _verify_emulator(self):
+ telnet_ok = False
+ tn = None
+ while(not telnet_ok):
+ try:
+ tn = telnetlib.Telnet('localhost', self.avd_info.port, 10)
+ if tn is not None:
+ tn.read_until('OK', 10)
+ self._telnet_cmd(tn, 'avd status')
+ self._telnet_cmd(tn, 'redir list')
+ self._telnet_cmd(tn, 'network status')
+ tn.write('quit\n')
+ tn.read_all()
+ telnet_ok = True
+ else:
+ _log_warning("Unable to connect to port %d" % self.avd_info.port)
+ except:
+ _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.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:
+ if not self.substs['TARGET_CPU'].startswith('arm'):
+ return 'x86'
+ return '4.3'
+
+
+def _find_sdk_exe(substs, exe, tools):
+ if tools:
+ subdir = 'tools'
+ else:
+ subdir = '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']
+ exe_path = os.path.join(
+ android_sdk_root, subdir, exe)
+ if os.path.exists(exe_path):
+ found = True
+ 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?
+ mozbuild_path = os.environ.get('MOZBUILD_STATE_PATH',
+ os.path.expanduser(os.path.join('~', '.mozbuild')))
+ exe_path = os.path.join(
+ mozbuild_path, 'android-sdk-linux', subdir, exe)
+ if os.path.exists(exe_path):
+ found = True
+ 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))
+ 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):
+ f = urllib2.urlopen(url)
+ if not os.path.isdir(path):
+ try:
+ os.makedirs(path)
+ except Exception, 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, 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():
+ def outputHandler(line):
+ _log_debug(line)
+ _download_file(TOOLTOOL_URL, 'tooltool.py', EMULATOR_HOME_DIR)
+ command = [sys.executable, 'tooltool.py',
+ 'fetch', '-o', '-m', 'releng.manifest']
+ proc = ProcessHandler(
+ command, processOutputLine=outputHandler, storeOutput=False,
+ cwd=EMULATOR_HOME_DIR)
+ proc.run()
+ try:
+ proc.wait()
+ except:
+ if proc.poll() is None:
+ proc.kill(signal.SIGTERM)
+
+
+def _get_host_platform():
+ plat = None
+ if 'darwin' in str(sys.platform).lower():
+ plat = 'macosx64'
+ elif 'linux' in str(sys.platform).lower():
+ if '64' in platform.architecture()[0]:
+ plat = 'linux64'
+ else:
+ plat = 'linux32'
+ return plat
+
+
+def _get_device_platform(substs):
+ # PIE executables are required when SDK level >= 21 - important for gdbserver
+ adb_path = _find_sdk_exe(substs, 'adb', False)
+ if not adb_path:
+ adb_path = 'adb'
+ dm = DeviceManagerADB(autoconnect=False, adbPath=adb_path, retryLimit=1)
+ sdk_level = None
+ try:
+ cmd = ['getprop', 'ro.build.version.sdk']
+ _log_debug(cmd)
+ output = dm.shellCheckOutput(cmd, timeout=10)
+ if output:
+ sdk_level = int(output)
+ except:
+ _log_warning("unable to determine Android sdk level")
+ pie = ''
+ if sdk_level and sdk_level >= 21:
+ pie = '-pie'
+ if substs['TARGET_CPU'].startswith('arm'):
+ return 'arm%s' % pie
+ return 'x86%s' % pie
+
+
+def _update_gdbinit(substs, path):
+ if os.path.exists(path):
+ obj_replaced = False
+ src_replaced = False
+ # update existing objdir/srcroot in place
+ for line in fileinput.input(path, inplace=True):
+ if "feninit.default.objdir" in line and substs and 'MOZ_BUILD_ROOT' in substs:
+ print("python feninit.default.objdir = '%s'" % substs['MOZ_BUILD_ROOT'])
+ obj_replaced = True
+ elif "feninit.default.srcroot" in line and substs and 'top_srcdir' in substs:
+ print("python feninit.default.srcroot = '%s'" % substs['top_srcdir'])
+ src_replaced = True
+ else:
+ print(line.strip())
+ # append objdir/srcroot if not updated
+ if (not obj_replaced) and substs and 'MOZ_BUILD_ROOT' in substs:
+ with open(path, "a") as f:
+ f.write("\npython feninit.default.objdir = '%s'\n" % substs['MOZ_BUILD_ROOT'])
+ if (not src_replaced) and substs and 'top_srcdir' in substs:
+ with open(path, "a") as f:
+ f.write("python feninit.default.srcroot = '%s'\n" % substs['top_srcdir'])
+ else:
+ # write objdir/srcroot to new gdbinit file
+ with open(path, "w") as f:
+ if substs and 'MOZ_BUILD_ROOT' in substs:
+ f.write("python feninit.default.objdir = '%s'\n" % substs['MOZ_BUILD_ROOT'])
+ if substs and 'top_srcdir' in substs:
+ f.write("python feninit.default.srcroot = '%s'\n" % substs['top_srcdir'])
diff --git a/testing/mozbase/mozrunner/mozrunner/devices/autophone.py b/testing/mozbase/mozrunner/mozrunner/devices/autophone.py
new file mode 100644
index 000000000..3b4913028
--- /dev/null
+++ b/testing/mozbase/mozrunner/mozrunner/devices/autophone.py
@@ -0,0 +1,651 @@
+# 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 json
+import logging
+import os
+import shutil
+import signal
+import socket
+import sys
+import threading
+import time
+import which
+import BaseHTTPServer
+import SimpleHTTPServer
+
+from mozbuild.virtualenv import VirtualenvManager
+from mozdevice import DeviceManagerADB
+from mozprocess import ProcessHandler
+
+
+class AutophoneRunner(object):
+ """
+ Supporting the mach 'autophone' command: configure, run autophone.
+ """
+ config = {'base-dir': None,
+ 'requirements-installed': False,
+ 'devices-configured': False,
+ 'test-manifest': None}
+ CONFIG_FILE = os.path.join(os.path.expanduser('~'), '.mozbuild', 'autophone.json')
+
+ def __init__(self, build_obj, verbose):
+ self.build_obj = build_obj
+ self.verbose = verbose
+ self.autophone_options = []
+ self.httpd = None
+ self.webserver_required = False
+
+ def reset_to_clean(self):
+ """
+ If confirmed, remove the autophone directory and configuration.
+ """
+ dir = self.config['base-dir']
+ if dir and os.path.exists(dir) and os.path.exists(self.CONFIG_FILE):
+ self.build_obj.log(logging.WARN, "autophone", {},
+ "*** This will delete %s and reset your "
+ "'mach autophone' configuration! ***" % dir)
+ response = raw_input(
+ "Proceed with deletion? (y/N) ").strip()
+ if response.lower().startswith('y'):
+ os.remove(self.CONFIG_FILE)
+ shutil.rmtree(dir)
+ else:
+ self.build_obj.log(logging.INFO, "autophone", {},
+ "Already clean -- nothing to do!")
+
+ def save_config(self):
+ """
+ Persist self.config to a file.
+ """
+ try:
+ with open(self.CONFIG_FILE, 'w') as f:
+ json.dump(self.config, f)
+ if self.verbose:
+ print("saved configuration: %s" % self.config)
+ except:
+ self.build_obj.log(logging.ERROR, "autophone", {},
+ "unable to save 'mach autophone' "
+ "configuration to %s" % self.CONFIG_FILE)
+ if self.verbose:
+ self.build_obj.log(logging.ERROR, "autophone", {},
+ str(sys.exc_info()[0]))
+
+ def load_config(self):
+ """
+ Import the configuration info saved by save_config().
+ """
+ if os.path.exists(self.CONFIG_FILE):
+ try:
+ with open(self.CONFIG_FILE, 'r') as f:
+ self.config = json.load(f)
+ if self.verbose:
+ print("loaded configuration: %s" % self.config)
+ except:
+ self.build_obj.log(logging.ERROR, "autophone", {},
+ "unable to load 'mach autophone' "
+ "configuration from %s" % self.CONFIG_FILE)
+ if self.verbose:
+ self.build_obj.log(logging.ERROR, "autophone", {},
+ str(sys.exc_info()[0]))
+
+ def setup_directory(self):
+ """
+ Find the autophone source code location, or download if necessary.
+ """
+ keep_going = True
+ dir = self.config['base-dir']
+ if not dir:
+ dir = os.path.join(os.path.expanduser('~'), 'mach-autophone')
+ if os.path.exists(os.path.join(dir, '.git')):
+ response = raw_input(
+ "Run autophone from existing directory, %s (Y/n) " % dir).strip()
+ if 'n' not in response.lower():
+ self.build_obj.log(logging.INFO, "autophone", {},
+ "Configuring and running autophone at %s" % dir)
+ return keep_going
+ self.build_obj.log(logging.INFO, "autophone", {},
+ "Unable to find an existing autophone directory. "
+ "Let's setup a new one...")
+ response = raw_input(
+ "Enter location of new autophone directory: [%s] " % dir).strip()
+ if response != '':
+ dir = response
+ self.config['base-dir'] = dir
+ if not os.path.exists(os.path.join(dir, '.git')):
+ self.build_obj.log(logging.INFO, "autophone", {},
+ "Cloning autophone repository to '%s'..." % dir)
+ self.config['requirements-installed'] = False
+ self.config['devices-configured'] = False
+ self.run_process(['git', 'clone', 'https://github.com/mozilla/autophone', dir])
+ self.run_process(['git', 'submodule', 'update', '--init', '--remote'], cwd=dir)
+ if not os.path.exists(os.path.join(dir, '.git')):
+ # git not installed? File permission problem? github not available?
+ self.build_obj.log(logging.ERROR, "autophone", {},
+ "Unable to clone autophone directory.")
+ if not self.verbose:
+ self.build_obj.log(logging.ERROR, "autophone", {},
+ "Try re-running this command with --verbose to get more info.")
+ keep_going = False
+ return keep_going
+
+ def install_requirements(self):
+ """
+ Install required python modules in a virtualenv rooted at <autophone>/_virtualenv.
+ """
+ keep_going = True
+ dir = self.config['base-dir']
+ vdir = os.path.join(dir, '_virtualenv')
+ self.auto_virtualenv_manager = VirtualenvManager(self.build_obj.topsrcdir,
+ self.build_obj.topobjdir,
+ vdir, sys.stdout,
+ os.path.join(self.build_obj.topsrcdir,
+ 'build',
+ 'virtualenv_packages.txt'))
+ if not self.config['requirements-installed'] or not os.path.exists(vdir):
+ self.build_obj.log(logging.INFO, "autophone", {},
+ "Installing required modules in a virtualenv...")
+ self.auto_virtualenv_manager.build()
+ self.auto_virtualenv_manager._run_pip(['install', '-r',
+ os.path.join(dir, 'requirements.txt')])
+ self.config['requirements-installed'] = True
+ return keep_going
+
+ def configure_devices(self):
+ """
+ Ensure devices.ini is set up.
+ """
+ keep_going = True
+ device_ini = os.path.join(self.config['base-dir'], 'devices.ini')
+ if os.path.exists(device_ini):
+ response = raw_input(
+ "Use existing device configuration at %s? (Y/n) " % device_ini).strip()
+ if 'n' not in response.lower():
+ self.build_obj.log(logging.INFO, "autophone", {},
+ "Using device configuration at %s" % device_ini)
+ return keep_going
+ keep_going = False
+ self.build_obj.log(logging.INFO, "autophone", {},
+ "You must configure at least one Android device "
+ "before running autophone.")
+ response = raw_input(
+ "Configure devices now? (Y/n) ").strip()
+ if response.lower().startswith('y') or response == '':
+ response = raw_input(
+ "Connect your rooted Android test device(s) with usb and press Enter ")
+ adb_path = 'adb'
+ try:
+ if os.path.exists(self.build_obj.substs["ADB"]):
+ adb_path = self.build_obj.substs["ADB"]
+ except:
+ if self.verbose:
+ self.build_obj.log(logging.ERROR, "autophone", {},
+ str(sys.exc_info()[0]))
+ # No build environment?
+ try:
+ adb_path = which.which('adb')
+ except which.WhichError:
+ adb_path = raw_input(
+ "adb not found. Enter path to adb: ").strip()
+ if self.verbose:
+ print("Using adb at %s" % adb_path)
+ dm = DeviceManagerADB(autoconnect=False, adbPath=adb_path, retryLimit=1)
+ device_index = 1
+ try:
+ with open(os.path.join(self.config['base-dir'], 'devices.ini'), 'w') as f:
+ for device in dm.devices():
+ serial = device[0]
+ if self.verify_device(adb_path, serial):
+ f.write("[device-%d]\nserialno=%s\n" % (device_index, serial))
+ device_index += 1
+ self.build_obj.log(logging.INFO, "autophone", {},
+ "Added '%s' to device configuration." % serial)
+ keep_going = True
+ else:
+ self.build_obj.log(logging.WARNING, "autophone", {},
+ "Device '%s' is not rooted - skipping" % serial)
+ except:
+ self.build_obj.log(logging.ERROR, "autophone", {},
+ "Failed to get list of connected Android devices.")
+ if self.verbose:
+ self.build_obj.log(logging.ERROR, "autophone", {},
+ str(sys.exc_info()[0]))
+ keep_going = False
+ if device_index <= 1:
+ self.build_obj.log(logging.ERROR, "autophone", {},
+ "No devices configured! (Can you see your rooted test device(s)"
+ " in 'adb devices'?")
+ keep_going = False
+ if keep_going:
+ self.config['devices-configured'] = True
+ return keep_going
+
+ def configure_tests(self):
+ """
+ Determine the required autophone --test-path option.
+ """
+ dir = self.config['base-dir']
+ self.build_obj.log(logging.INFO, "autophone", {},
+ "Autophone must be started with a 'test manifest' "
+ "describing the type(s) of test(s) to run.")
+ test_options = []
+ for ini in glob.glob(os.path.join(dir, 'tests', '*.ini')):
+ with open(ini, 'r') as f:
+ content = f.readlines()
+ for line in content:
+ if line.startswith('# @mach@ '):
+ webserver = False
+ if '@webserver@' in line:
+ webserver = True
+ line = line.replace('@webserver@', '')
+ test_options.append((line[9:].strip(), ini, webserver))
+ break
+ if len(test_options) >= 1:
+ test_options.sort()
+ self.build_obj.log(logging.INFO, "autophone", {},
+ "These test manifests are available:")
+ index = 1
+ for option in test_options:
+ print("%d. %s" % (index, option[0]))
+ index += 1
+ highest = index - 1
+ path = None
+ while not path:
+ path = None
+ self.webserver_required = False
+ response = raw_input(
+ "Select test manifest (1-%d, or path to test manifest) " % highest).strip()
+ if os.path.isfile(response):
+ path = response
+ self.config['test-manifest'] = path
+ # Assume a webserver is required; if it isn't, user can provide a dummy url.
+ self.webserver_required = True
+ else:
+ try:
+ choice = int(response)
+ if choice >= 1 and choice <= highest:
+ path = test_options[choice - 1][1]
+ if test_options[choice - 1][2]:
+ self.webserver_required = True
+ else:
+ self.build_obj.log(logging.ERROR, "autophone", {},
+ "'%s' invalid: Enter a number between "
+ "1 and %d!" % (response, highest))
+ except ValueError:
+ self.build_obj.log(logging.ERROR, "autophone", {},
+ "'%s' unrecognized: Enter a number between "
+ "1 and %d!" % (response, highest))
+ self.autophone_options.extend(['--test-path', path])
+ else:
+ # Provide a simple backup for the unusual case where test manifests
+ # cannot be found.
+ response = ""
+ default = self.config['test-manifest'] or ""
+ while not os.path.isfile(response):
+ response = raw_input(
+ "Enter path to a test manifest: [%s] " % default).strip()
+ if response == "":
+ response = default
+ self.autophone_options.extend(['--test-path', response])
+ self.config['test-manifest'] = response
+ # Assume a webserver is required; if it isn't, user can provide a dummy url.
+ self.webserver_required = True
+
+ return True
+
+ def write_unittest_defaults(self, defaults_path, xre_path):
+ """
+ Write unittest-defaults.ini.
+ """
+ try:
+ # This should be similar to unittest-defaults.ini.example
+ with open(defaults_path, 'w') as f:
+ f.write("""\
+# Created by 'mach autophone'
+[runtests]
+xre_path = %s
+utility_path = %s
+console_level = DEBUG
+log_level = DEBUG
+time_out = 300""" % (xre_path, xre_path))
+ if self.verbose:
+ print("Created %s with host utilities path %s" % (defaults_path, xre_path))
+ except:
+ self.build_obj.log(logging.ERROR, "autophone", {},
+ "Unable to create %s" % defaults_path)
+ if self.verbose:
+ self.build_obj.log(logging.ERROR, "autophone", {},
+ str(sys.exc_info()[0]))
+
+ def configure_unittests(self):
+ """
+ Ensure unittest-defaults.ini is set up.
+ """
+ defaults_path = os.path.join(self.config['base-dir'], 'configs', 'unittest-defaults.ini')
+ if os.path.isfile(defaults_path):
+ response = raw_input(
+ "Use existing unit test configuration at %s? (Y/n) " % defaults_path).strip()
+ if 'n' in response.lower():
+ os.remove(defaults_path)
+ if not os.path.isfile(defaults_path):
+ xre_path = os.environ.get('MOZ_HOST_BIN')
+ if not xre_path or not os.path.isdir(xre_path):
+ emulator_path = os.path.join(os.path.expanduser('~'), '.mozbuild',
+ 'android-device')
+ xre_paths = glob.glob(os.path.join(emulator_path, 'host-utils*'))
+ for xre_path in xre_paths:
+ if os.path.isdir(xre_path):
+ break
+ if not xre_path or not os.path.isdir(xre_path) or \
+ not os.path.isfile(os.path.join(xre_path, 'xpcshell')):
+ self.build_obj.log(logging.INFO, "autophone", {},
+ "Some tests require access to 'host utilities' "
+ "such as xpcshell.")
+ xre_path = raw_input(
+ "Enter path to host utilities directory: ").strip()
+ if not xre_path or not os.path.isdir(xre_path) or \
+ not os.path.isfile(os.path.join(xre_path, 'xpcshell')):
+ self.build_obj.log(
+ logging.ERROR, "autophone", {},
+ "Unable to configure unit tests - no path to host utilities.")
+ return False
+ self.write_unittest_defaults(defaults_path, xre_path)
+ if os.path.isfile(defaults_path):
+ self.build_obj.log(logging.INFO, "autophone", {},
+ "Using unit test configuration at %s" % defaults_path)
+ return True
+
+ def configure_ip(self):
+ """
+ Determine what IP should be used for the autophone --ipaddr option.
+ """
+ # Take a guess at the IP to suggest. This won't always get the "right" IP,
+ # but will save some typing, sometimes.
+ s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
+ s.connect(('8.8.8.8', 0))
+ ip = s.getsockname()[0]
+ response = raw_input(
+ "IP address of interface to use for phone callbacks [%s] " % ip).strip()
+ if response == "":
+ response = ip
+ self.autophone_options.extend(['--ipaddr', response])
+ self.ipaddr = response
+ return True
+
+ def configure_webserver(self):
+ """
+ Determine the autophone --webserver-url option.
+ """
+ if self.webserver_required:
+ self.build_obj.log(logging.INFO, "autophone", {},
+ "Some of your selected tests require a webserver.")
+ response = raw_input("Start a webserver now? [Y/n] ").strip()
+ parts = []
+ while len(parts) != 2:
+ response2 = raw_input(
+ "Webserver address? [%s:8100] " % self.ipaddr).strip()
+ if response2 == "":
+ parts = [self.ipaddr, "8100"]
+ else:
+ parts = response2.split(":")
+ if len(parts) == 2:
+ addr = parts[0]
+ try:
+ port = int(parts[1])
+ if port <= 0:
+ self.build_obj.log(
+ logging.ERROR, "autophone", {},
+ "Port must be > 0. "
+ "Enter webserver address in the format <ip>:<port>")
+ parts = []
+ except ValueError:
+ self.build_obj.log(
+ logging.ERROR, "autophone", {},
+ "Port must be a number. "
+ "Enter webserver address in the format <ip>:<port>")
+ parts = []
+ else:
+ self.build_obj.log(
+ logging.ERROR, "autophone", {},
+ "Enter webserver address in the format <ip>:<port>")
+ if not ('n' in response.lower()):
+ self.launch_webserver(addr, port)
+ self.autophone_options.extend(['--webserver-url',
+ 'http://%s:%d' % (addr, port)])
+ return True
+
+ def configure_other(self):
+ """
+ Advanced users may set up additional options in autophone.ini.
+ Find and handle that case silently.
+ """
+ path = os.path.join(self.config['base-dir'], 'autophone.ini')
+ if os.path.isfile(path):
+ self.autophone_options.extend(['--config', path])
+ return True
+
+ def configure(self):
+ """
+ Ensure all configuration files are set up and determine autophone options.
+ """
+ return self.configure_devices() and \
+ self.configure_unittests() and \
+ self.configure_tests() and \
+ self.configure_ip() and \
+ self.configure_webserver() and \
+ self.configure_other()
+
+ def verify_device(self, adb_path, device):
+ """
+ Check that the specified device is available and rooted.
+ """
+ try:
+ dm = DeviceManagerADB(adbPath=adb_path, retryLimit=1, deviceSerial=device)
+ if dm._haveSu or dm._haveRootShell:
+ return True
+ except:
+ self.build_obj.log(
+ logging.WARN, "autophone", {},
+ "Unable to verify root on device.")
+ if self.verbose:
+ self.build_obj.log(logging.ERROR, "autophone", {},
+ str(sys.exc_info()[0]))
+ return False
+
+ def launch_autophone(self):
+ """
+ Launch autophone in its own thread and wait for autophone startup.
+ """
+ self.build_obj.log(logging.INFO, "autophone", {},
+ "Launching autophone...")
+ self.thread = threading.Thread(target=self.run_autophone)
+ self.thread.start()
+ # Wait for startup, so that autophone startup messages do not get mixed
+ # in with our interactive command prompts.
+ dir = self.config['base-dir']
+ started = False
+ for seconds in [5, 5, 3, 3, 1, 1, 1, 1]:
+ time.sleep(seconds)
+ if self.run_process(['./ap.sh', 'autophone-status'], cwd=dir, dump=False):
+ started = True
+ break
+ time.sleep(1)
+ if not started:
+ self.build_obj.log(logging.WARN, "autophone", {},
+ "Autophone is taking longer than expected to start.")
+
+ def run_autophone(self):
+ dir = self.config['base-dir']
+ cmd = [self.auto_virtualenv_manager.python_path, 'autophone.py']
+ cmd.extend(self.autophone_options)
+ self.run_process(cmd, cwd=dir, dump=True)
+
+ def command_prompts(self):
+ """
+ Interactive command prompts: Provide access to ap.sh and trigger_runs.py.
+ """
+ dir = self.config['base-dir']
+ if self.thread.isAlive():
+ self.build_obj.log(
+ logging.INFO, "autophone", {},
+ "Use 'trigger' to select builds to test using the current test manifest.")
+ self.build_obj.log(
+ logging.INFO, "autophone", {},
+ "Type 'trigger', 'help', 'quit', or an autophone command.")
+ quitting = False
+ while self.thread.isAlive() and not quitting:
+ response = raw_input(
+ "autophone command? ").strip().lower()
+ if response == "help":
+ self.run_process(['./ap.sh', 'autophone-help'], cwd=dir, dump=True)
+ print("""\
+
+Additional commands available in this interactive shell:
+
+trigger
+ Initiate autophone test runs. You will be prompted for a set of builds
+ to run tests against. (To run a different type of test, quit, run this
+ mach command again, and select a new test manifest.)
+
+quit
+ Shutdown autophone and exit this shell (short-cut to 'autophone-shutdown')
+
+ """)
+ continue
+ if response == "trigger":
+ self.trigger_prompts()
+ continue
+ if response == "quit":
+ self.build_obj.log(logging.INFO, "autophone", {},
+ "Quitting...")
+ response = "autophone-shutdown"
+ if response == "autophone-shutdown":
+ quitting = True
+ self.run_process(['./ap.sh', response], cwd=dir, dump=True)
+ if self.httpd:
+ self.httpd.shutdown()
+ self.thread.join()
+
+ def trigger_prompts(self):
+ """
+ Sub-prompts for the "trigger" command.
+ """
+ dir = self.config['base-dir']
+ self.build_obj.log(
+ logging.INFO, "autophone", {},
+ "Tests will be run against a build or collection of builds, selected by:")
+ print("""\
+1. The latest build
+2. Build URL
+3. Build ID
+4. Date/date-time range\
+ """)
+ highest = 4
+ choice = 0
+ while (choice < 1 or choice > highest):
+ response = raw_input(
+ "Build selection type? (1-%d) " % highest).strip()
+ try:
+ choice = int(response)
+ except ValueError:
+ self.build_obj.log(logging.ERROR, "autophone", {},
+ "Enter a number between 1 and %d" % highest)
+ choice = 0
+ if choice == 1:
+ options = ["latest"]
+ elif choice == 2:
+ url = raw_input(
+ "Enter url of build to test; may be an http or file schema ").strip()
+ options = ["--build-url=%s" % url]
+ elif choice == 3:
+ response = raw_input(
+ "Enter Build ID, eg 20120403063158 ").strip()
+ options = [response]
+ elif choice == 4:
+ start = raw_input(
+ "Enter start build date or date-time, "
+ "e.g. 2012-04-03 or 2012-04-03T06:31:58 ").strip()
+ end = raw_input(
+ "Enter end build date or date-time, "
+ "e.g. 2012-04-03 or 2012-04-03T06:31:58 ").strip()
+ options = [start, end]
+ self.build_obj.log(
+ logging.INFO, "autophone", {},
+ "You may optionally specify a repository name like 'mozilla-inbound' or 'try'.")
+ self.build_obj.log(
+ logging.INFO, "autophone", {},
+ "If not specified, 'mozilla-central' is assumed.")
+ repo = raw_input(
+ "Enter repository name: ").strip()
+ if len(repo) > 0:
+ options.extend(["--repo=%s" % repo])
+ if repo == "mozilla-central" or repo == "mozilla-aurora" or len(repo) < 1:
+ self.build_obj.log(
+ logging.INFO, "autophone", {},
+ "You may optionally specify the build location, like 'nightly' or 'tinderbox'.")
+ location = raw_input(
+ "Enter build location: ").strip()
+ if len(location) > 0:
+ options.extend(["--build-location=%s" % location])
+ else:
+ options.extend(["--build-location=tinderbox"])
+ cmd = [self.auto_virtualenv_manager.python_path, "trigger_runs.py"]
+ cmd.extend(options)
+ self.build_obj.log(
+ logging.INFO, "autophone", {},
+ "Triggering...Tests will run once builds have been downloaded.")
+ self.build_obj.log(logging.INFO, "autophone", {},
+ "Use 'autophone-status' to check progress.")
+ self.run_process(cmd, cwd=dir, dump=True)
+
+ def launch_webserver(self, addr, port):
+ """
+ Launch the webserver (in a separate thread).
+ """
+ self.build_obj.log(logging.INFO, "autophone", {},
+ "Launching webserver...")
+ self.webserver_addr = addr
+ self.webserver_port = port
+ self.threadweb = threading.Thread(target=self.run_webserver)
+ self.threadweb.start()
+
+ def run_webserver(self):
+ class AutoHTTPRequestHandler(SimpleHTTPServer.SimpleHTTPRequestHandler):
+ # A simple request handler with logging suppressed.
+
+ def log_message(self, format, *args):
+ pass
+
+ os.chdir(self.config['base-dir'])
+ address = (self.webserver_addr, self.webserver_port)
+ self.httpd = BaseHTTPServer.HTTPServer(address, AutoHTTPRequestHandler)
+ try:
+ self.httpd.serve_forever()
+ except KeyboardInterrupt:
+ print("Web server interrupted.")
+
+ def run_process(self, cmd, cwd=None, dump=False):
+ def _processOutput(line):
+ if self.verbose or dump:
+ print(line)
+
+ if self.verbose:
+ self.build_obj.log(logging.INFO, "autophone", {},
+ "Running '%s' in '%s'" % (cmd, cwd))
+ proc = ProcessHandler(cmd, cwd=cwd, processOutputLine=_processOutput,
+ processStderrLine=_processOutput)
+ proc.run()
+ proc_complete = False
+ try:
+ proc.wait()
+ if proc.proc.returncode == 0:
+ proc_complete = True
+ except:
+ if proc.poll() is None:
+ proc.kill(signal.SIGTERM)
+ if not proc_complete:
+ if not self.verbose:
+ print(proc.output)
+ return proc_complete
diff --git a/testing/mozbase/mozrunner/mozrunner/devices/base.py b/testing/mozbase/mozrunner/mozrunner/devices/base.py
new file mode 100644
index 000000000..b5cf2b58f
--- /dev/null
+++ b/testing/mozbase/mozrunner/mozrunner/devices/base.py
@@ -0,0 +1,306 @@
+from ConfigParser import (
+ ConfigParser,
+ RawConfigParser
+)
+import datetime
+import os
+import posixpath
+import re
+import shutil
+import socket
+import subprocess
+import tempfile
+import time
+import traceback
+
+from mozdevice import DMError
+from mozprocess import ProcessHandler
+
+
+class Device(object):
+ connected = False
+ logcat_proc = None
+
+ def __init__(self, app_ctx, logdir=None, serial=None, restore=True):
+ self.app_ctx = app_ctx
+ self.dm = self.app_ctx.dm
+ 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.dm.fileExists(remote_ini):
+ raise IOError("Remote file '%s' not found" % remote_ini)
+
+ local_ini = tempfile.NamedTemporaryFile()
+ self.dm.getFile(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()
+ self.dm.getDirectory(remote_dump_dir, local_dump_dir)
+ if os.listdir(local_dump_dir):
+ for f in self.dm.listFiles(remote_dump_dir):
+ self.dm.removeFile(posixpath.join(remote_dump_dir, f))
+ 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.
+ """
+ self.dm.remount()
+
+ if self.dm.dirExists(self.app_ctx.remote_profile):
+ self.dm.shellCheckOutput(['rm', '-r', self.app_ctx.remote_profile])
+
+ self.dm.pushDir(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.dm.fileExists(self.app_ctx.remote_profiles_ini):
+ break
+ time.sleep(1)
+ else:
+ print "timed out waiting for profiles.ini"
+
+ local_profiles_ini = tempfile.NamedTemporaryFile()
+ self.dm.getFile(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.dm.pushFile(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):
+ return [d[0] for d in self.dm.devices()
+ if d[1] != 'offline'
+ if not d[0].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
+
+ if self.serial:
+ serial = self.serial
+ else:
+ 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.")
+ serial = online_devices[0]
+
+ self.dm._deviceSerial = serial
+ self.dm.connect()
+ self.connected = True
+
+ if self.logdir:
+ # save logcat
+ logcat_log = os.path.join(self.logdir, '%s.log' % serial)
+ if os.path.isfile(logcat_log):
+ self._rotate_log(logcat_log)
+ self.logcat_proc = self.start_logcat(serial, logfile=logcat_log)
+
+ def start_logcat(self, serial, logfile=None, stream=None, filterspec=None):
+ logcat_args = [self.app_ctx.adb, '-s', '%s' % serial,
+ 'logcat', '-v', 'time', '-b', 'main', '-b', 'radio']
+ # only log filterspec
+ if filterspec:
+ logcat_args.extend(['-s', filterspec])
+ process_args = {}
+ if logfile:
+ process_args['logfile'] = logfile
+ elif stream:
+ process_args['stream'] = stream
+ proc = ProcessHandler(logcat_args, **process_args)
+ proc.run()
+ return proc
+
+ def reboot(self):
+ """
+ Reboots the device via adb.
+ """
+ self.dm.reboot(wait=True)
+
+ def install_busybox(self, busybox):
+ """
+ Installs busybox on the device.
+
+ :param busybox: Path to busybox binary to install.
+ """
+ self.dm.remount()
+ print 'pushing %s' % self.app_ctx.remote_busybox
+ self.dm.pushFile(busybox, self.app_ctx.remote_busybox, retryLimit=10)
+ # TODO for some reason using dm.shellCheckOutput doesn't work,
+ # while calling adb shell directly does.
+ args = [self.app_ctx.adb, '-s', self.dm._deviceSerial,
+ 'shell', 'cd /system/bin; chmod 555 busybox;'
+ 'for x in `./busybox --list`; do ln -s ./busybox $x; done']
+ adb = subprocess.Popen(args, stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
+ adb.wait()
+ self.dm._verifyZip()
+
+ def wait_for_net(self):
+ active = False
+ time_out = 0
+ while not active and time_out < 40:
+ proc = subprocess.Popen([self.app_ctx.adb, 'shell', '/system/bin/netcfg'],
+ stdout=subprocess.PIPE)
+ proc.stdout.readline() # ignore first line
+ line = proc.stdout.readline()
+ while line != "":
+ if (re.search(r'UP\s+[1-9]\d{0,2}\.\d{1,3}\.\d{1,3}\.\d{1,3}', line)):
+ active = True
+ break
+ line = proc.stdout.readline()
+ time_out += 1
+ time.sleep(1)
+ return active
+
+ def wait_for_port(self, port, timeout=300):
+ starttime = datetime.datetime.now()
+ while datetime.datetime.now() - starttime < datetime.timedelta(seconds=timeout):
+ try:
+ sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+ sock.connect(('localhost', port))
+ data = sock.recv(16)
+ sock.close()
+ if ':' in data:
+ return True
+ except:
+ traceback.print_exc()
+ time.sleep(1)
+ return False
+
+ def backup_file(self, remote_path):
+ if not self.restore:
+ return
+
+ if self.dm.fileExists(remote_path) or self.dm.dirExists(remote_path):
+ self.dm.copyTree(remote_path, '%s.orig' % remote_path)
+ 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.dm._verifyDevice()
+ except DMError:
+ return
+
+ self.dm.remount()
+ # Restore the original profile
+ for added_file in self.added_files:
+ self.dm.removeFile(added_file)
+
+ for backup_file in self.backup_files:
+ if self.dm.fileExists('%s.orig' % backup_file) or \
+ self.dm.dirExists('%s.orig' % backup_file):
+ self.dm.moveTree('%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.dm.removeDir(self.app_ctx.remote_profile)
+
+ 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 000000000..adeae27ed
--- /dev/null
+++ b/testing/mozbase/mozrunner/mozrunner/devices/emulator.py
@@ -0,0 +1,288 @@
+# 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 telnetlib import Telnet
+import datetime
+import os
+import shutil
+import subprocess
+import tempfile
+import time
+
+from mozprocess import ProcessHandler
+
+from .base import Device
+from .emulator_battery import EmulatorBattery
+from .emulator_geo import EmulatorGeo
+from .emulator_screen import EmulatorScreen
+from ..errors import TimeoutException
+
+
+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):
+ return [d[0] for d in self.dm.devices() if d[1] != 'offline' if
+ d[0].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()
+ serial = self.serial or self.dm._deviceSerial
+ self.port = int(serial[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
+ 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 Emulator(BaseEmulator):
+
+ def __init__(self, app_ctx, arch, resolution=None, sdcard=None, userdata=None,
+ no_window=None, binary=None, **kwargs):
+ super(Emulator, self).__init__(app_ctx, arch=arch, binary=binary, **kwargs)
+
+ # emulator args
+ self.resolution = resolution or '320x480'
+ self._sdcard_size = sdcard
+ self._sdcard = None
+ self.userdata = tempfile.NamedTemporaryFile(prefix='userdata-qemu', dir=self.tmpdir)
+ self.initdata = userdata if userdata else os.path.join(self.arch.sysdir, 'userdata.img')
+ self.no_window = no_window
+
+ @property
+ def sdcard(self):
+ if self._sdcard_size and not self._sdcard:
+ self._sdcard = SDCard(self, self._sdcard_size).path
+ else:
+ return self._sdcard
+
+ @property
+ def args(self):
+ """
+ Arguments to pass into the emulator binary.
+ """
+ qemu_args = super(Emulator, self).args
+ qemu_args.extend([
+ '-kernel', self.arch.kernel,
+ '-sysdir', self.arch.sysdir,
+ '-data', self.userdata.name,
+ '-initdata', self.initdata,
+ '-wipe-data'])
+ if self.no_window:
+ qemu_args.append('-no-window')
+ if self.sdcard:
+ qemu_args.extend(['-sdcard', self.sdcard])
+ qemu_args.extend(['-memory', '512',
+ '-partition-size', '512',
+ '-verbose',
+ '-skin', self.resolution,
+ '-gpu', 'on',
+ '-qemu'] + self.arch.extra_args)
+ return qemu_args
+
+ 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(Emulator, self).connect()
+ self.geo.set_default_location()
+ self.screen.initialize()
+
+ # setup DNS fix for networking
+ self.app_ctx.dm.shellCheckOutput(['setprop', 'net.dns1', '10.0.2.3'])
+
+ def cleanup(self):
+ """
+ Cleans up and kills the emulator, if it was started by mozrunner.
+ """
+ super(Emulator, self).cleanup()
+ # Remove temporary files
+ self.userdata.close()
+
+
+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 000000000..6f389152b
--- /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
+
+ 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 000000000..a8ec0e089
--- /dev/null
+++ b/testing/mozbase/mozrunner/mozrunner/devices/emulator_geo.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/.
+
+
+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 000000000..58bdda812
--- /dev/null
+++ b/testing/mozbase/mozrunner/mozrunner/devices/emulator_screen.py
@@ -0,0 +1,89 @@
+# 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 000000000..2c4ea50d5
--- /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/resources/metrotestharness.exe b/testing/mozbase/mozrunner/mozrunner/resources/metrotestharness.exe
new file mode 100644
index 000000000..d3bcbfbee
--- /dev/null
+++ b/testing/mozbase/mozrunner/mozrunner/resources/metrotestharness.exe
Binary files differ
diff --git a/testing/mozbase/mozrunner/mozrunner/runners.py b/testing/mozbase/mozrunner/mozrunner/runners.py
new file mode 100644
index 000000000..4d8e3e130
--- /dev/null
+++ b/testing/mozbase/mozrunner/mozrunner/runners.py
@@ -0,0 +1,211 @@
+# 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 or B2G emulator.
+"""
+
+from .application import get_app_context
+from .base import DeviceRunner, GeckoRuntimeRunner, FennecRunner
+from .devices import Emulator, EmulatorAVD, Device
+
+
+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 B2GDesktopRunner(*args, **kwargs):
+ """
+ Create a B2G desktop runner.
+
+ :param binary: Path to b2g desktop 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 b2g desktop.
+ """
+ # There is no difference between a generic and b2g desktop runner,
+ # but expose a separate entry point for clarity.
+ return Runner(*args, **kwargs)
+
+
+def FennecEmulatorRunner(avd='mozemulator-4.3',
+ 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 logcat and 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_args = {'app_ctx': kwargs['app_ctx'],
+ 'avd': avd,
+ 'binary': binary,
+ 'serial': serial,
+ 'logdir': logdir}
+ return FennecRunner(device_class=EmulatorAVD,
+ device_args=device_args,
+ **kwargs)
+
+
+def B2GEmulatorRunner(arch='arm',
+ b2g_home=None,
+ adb_path=None,
+ logdir=None,
+ binary=None,
+ no_window=None,
+ resolution=None,
+ sdcard=None,
+ userdata=None,
+ **kwargs):
+ """
+ Create a B2G emulator runner.
+
+ :param arch: The architecture of the emulator, either 'arm' or 'x86'. Defaults to 'arm'.
+ :param b2g_home: Path to root B2G repository.
+ :param logdir: Path to save logfiles such as logcat and qemu output.
+ :param no_window: Run emulator without a window.
+ :param resolution: Screen resolution to set emulator to, e.g '800x1000'.
+ :param sdcard: Path to local emulated sdcard storage.
+ :param userdata: Path to custom userdata image.
+ :param profile: Profile object to use.
+ :param env: Environment variables to pass into the b2g.sh process.
+ :param clean_profile: If True, restores profile back to original state.
+ :param process_class: Class used to launch the b2g.sh process.
+ :param process_args: Arguments to pass into the b2g.sh process.
+ :param symbols_path: Path to symbol files used for crash analysis.
+ :returns: A DeviceRunner for B2G emulators.
+ """
+ kwargs['app_ctx'] = get_app_context('b2g')(b2g_home, adb_path=adb_path)
+ device_args = {'app_ctx': kwargs['app_ctx'],
+ 'arch': arch,
+ 'binary': binary,
+ 'resolution': resolution,
+ 'sdcard': sdcard,
+ 'userdata': userdata,
+ 'no_window': no_window,
+ 'logdir': logdir}
+ return DeviceRunner(device_class=Emulator,
+ device_args=device_args,
+ **kwargs)
+
+
+def B2GDeviceRunner(b2g_home=None,
+ adb_path=None,
+ logdir=None,
+ serial=None,
+ **kwargs):
+ """
+ Create a B2G device runner.
+
+ :param b2g_home: Path to root B2G repository.
+ :param logdir: Path to save logfiles such as logcat.
+ :param serial: Serial of device to connect to as seen in `adb devices`.
+ :param profile: Profile object to use.
+ :param env: Environment variables to pass into the b2g.sh process.
+ :param clean_profile: If True, restores profile back to original state.
+ :param process_class: Class used to launch the b2g.sh process.
+ :param process_args: Arguments to pass into the b2g.sh process.
+ :param symbols_path: Path to symbol files used for crash analysis.
+ :returns: A DeviceRunner for B2G devices.
+ """
+ kwargs['app_ctx'] = get_app_context('b2g')(b2g_home, adb_path=adb_path)
+ device_args = {'app_ctx': kwargs['app_ctx'],
+ 'logdir': logdir,
+ 'serial': serial}
+ return DeviceRunner(device_class=Device,
+ device_args=device_args,
+ **kwargs)
+
+
+runners = {
+ 'default': Runner,
+ 'b2g_desktop': B2GDesktopRunner,
+ 'b2g_emulator': B2GEmulatorRunner,
+ 'b2g_device': B2GDeviceRunner,
+ 'firefox': FirefoxRunner,
+ 'thunderbird': ThunderbirdRunner,
+ 'fennec': FennecEmulatorRunner
+}
diff --git a/testing/mozbase/mozrunner/mozrunner/utils.py b/testing/mozbase/mozrunner/mozrunner/utils.py
new file mode 100755
index 000000000..f96c94398
--- /dev/null
+++ b/testing/mozbase/mozrunner/mozrunner/utils.py
@@ -0,0 +1,279 @@
+#!/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 mozinfo
+import os
+import sys
+
+__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,
+ dmdPath=None, lsanPath=None, 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
+ dmdLibrary = None
+ preloadEnvVar = None
+ if 'toolkit' in mozinfo.info and mozinfo.info['toolkit'] == "gonk":
+ # Skip all of this, it's only valid for the host.
+ pass
+ elif mozinfo.isUnix:
+ envVar = "LD_LIBRARY_PATH"
+ env['MOZILLA_FIVE_HOME'] = xrePath
+ dmdLibrary = "libdmd.so"
+ preloadEnvVar = "LD_PRELOAD"
+ elif mozinfo.isMac:
+ envVar = "DYLD_LIBRARY_PATH"
+ dmdLibrary = "libdmd.dylib"
+ preloadEnvVar = "DYLD_INSERT_LIBRARIES"
+ elif mozinfo.isWin:
+ envVar = "PATH"
+ dmdLibrary = "dmd.dll"
+ preloadEnvVar = "MOZ_REPLACE_MALLOC_LIB"
+ if envVar:
+ envValue = ((env.get(envVar), str(ldLibraryPath))
+ if mozinfo.isWin
+ else (ldLibraryPath, dmdPath, env.get(envVar)))
+ env[envVar] = os.path.pathsep.join([path for path in envValue if path])
+
+ if dmdPath and dmdLibrary and preloadEnvVar:
+ env[preloadEnvVar] = os.path.join(dmdPath, dmdLibrary)
+
+ # 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'
+ 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,MediaPipelineFactory:4'
+ )
+ env.setdefault('R_LOG_LEVEL', '6')
+ env.setdefault('R_LOG_DESTINATION', 'stderr')
+ env.setdefault('R_LOG_VERBOSE', '1')
+
+ # ASan specific environment stuff
+ asan = bool(mozinfo.info.get("asan"))
+ if asan and (mozinfo.isLinux or mozinfo.isMac):
+ try:
+ # Symbolizer support
+ llvmsym = os.path.join(xrePath, "llvm-symbolizer")
+ if os.path.isfile(llvmsym):
+ env["ASAN_SYMBOLIZER_PATH"] = llvmsym
+ log.info("INFO | runtests.py | ASan using symbolizer at %s"
+ % llvmsym)
+ else:
+ log.info("TEST-UNEXPECTED-FAIL | runtests.py | Failed to find"
+ " ASan symbolizer at %s" % llvmsym)
+
+ # Returns total system memory in kilobytes.
+ # Works only on unix-like platforms where `free` is in the path.
+ 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 slaves.
+ 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 lsanPath:
+ 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")
+ suppressionsFile = os.path.join(
+ lsanPath, 'lsan_suppressions.txt')
+ if os.path.exists(suppressionsFile):
+ log.info("LSan using suppression file " + suppressionsFile)
+ lsanOptions.append("suppressions=" + suppressionsFile)
+ else:
+ log.info("WARNING | runtests.py | LSan suppressions file"
+ " does not exist! " + suppressionsFile)
+ 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:
+ 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.
+ llvmsym = os.path.join(xrePath, "llvm-symbolizer")
+ 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.info("TEST-UNEXPECTED-FAIL | runtests.py | Failed to find TSan"
+ " symbolizer at %s" % llvmsym)
+
+ return env
+
+
+def get_stack_fixer_function(utilityPath, symbolsPath):
+ """
+ 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
+
+ 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 a function in fix_stack_using_bpsyms.py (uses breakpad
+ # symbol files).
+ # This method is preferred for Tinderbox builds, since native
+ # symbols may have been stripped.
+ stack_fixer_module = import_stack_fixer_module(
+ 'fix_stack_using_bpsyms')
+
+ def stack_fixer_function(line):
+ return stack_fixer_module.fixSymbols(line, symbolsPath)
+
+ elif mozinfo.isMac:
+ # Run each line through fix_macosx_stack.py (uses atos).
+ # This method is preferred for developer machines, so we don't
+ # have to run "make buildsymbols".
+ stack_fixer_module = import_stack_fixer_module(
+ 'fix_macosx_stack')
+
+ def stack_fixer_function(line):
+ return stack_fixer_module.fixSymbols(line)
+
+ elif mozinfo.isLinux:
+ # Run each line through fix_linux_stack.py (uses addr2line).
+ # This method is preferred for developer machines, so we don't
+ # have to run "make buildsymbols".
+ stack_fixer_module = import_stack_fixer_module(
+ 'fix_linux_stack')
+
+ def stack_fixer_function(line):
+ return stack_fixer_module.fixSymbols(line)
+
+ else:
+ return None
+
+ return stack_fixer_function
diff --git a/testing/mozbase/mozrunner/setup.py b/testing/mozbase/mozrunner/setup.py
new file mode 100644
index 000000000..23ffe88de
--- /dev/null
+++ b/testing/mozbase/mozrunner/setup.py
@@ -0,0 +1,54 @@
+# 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 sys
+from setuptools import setup, find_packages
+
+PACKAGE_NAME = 'mozrunner'
+PACKAGE_VERSION = '6.13'
+
+desc = """Reliable start/stop/configuration of Mozilla Applications (Firefox, Thunderbird, etc.)"""
+
+deps = ['mozdevice >= 0.37',
+ 'mozfile >= 1.0',
+ 'mozinfo >= 0.7',
+ 'mozlog >= 3.0',
+ 'mozprocess >= 0.23',
+ 'mozprofile >= 0.18',
+ ]
+
+EXTRAS_REQUIRE = {'crash': ['mozcrash >= 1.0']}
+
+# we only support python 2 right now
+assert sys.version_info[0] == 2
+
+setup(name=PACKAGE_NAME,
+ version=PACKAGE_VERSION,
+ description=desc,
+ long_description="see http://mozbase.readthedocs.org/",
+ 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',
+ '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(),
+ package_data={'mozrunner': [
+ 'resources/metrotestharness.exe'
+ ]},
+ 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/manifest.ini b/testing/mozbase/mozrunner/tests/manifest.ini
new file mode 100644
index 000000000..62af8fb30
--- /dev/null
+++ b/testing/mozbase/mozrunner/tests/manifest.ini
@@ -0,0 +1,7 @@
+[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/mozrunnertest.py b/testing/mozbase/mozrunner/tests/mozrunnertest.py
new file mode 100644
index 000000000..33f51031f
--- /dev/null
+++ b/testing/mozbase/mozrunner/tests/mozrunnertest.py
@@ -0,0 +1,34 @@
+# 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 unittest
+
+import mozprofile
+import mozrunner
+
+
+@unittest.skipIf(not os.environ.get('BROWSER_PATH'),
+ 'No binary has been specified.')
+class MozrunnerTestCase(unittest.TestCase):
+
+ def setUp(self):
+ self.pids = []
+ self.threads = []
+
+ self.profile = mozprofile.FirefoxProfile()
+ self.runner = mozrunner.FirefoxRunner(os.environ['BROWSER_PATH'],
+ profile=self.profile)
+
+ def tearDown(self):
+ for thread in self.threads:
+ thread.join()
+
+ self.runner.cleanup()
+
+ # Clean-up any left over and running processes
+ for pid in self.pids:
+ # TODO: Bug 925408
+ # mozprocess is not able yet to kill specific processes
+ pass
diff --git a/testing/mozbase/mozrunner/tests/test_crash.py b/testing/mozbase/mozrunner/tests/test_crash.py
new file mode 100644
index 000000000..455fc5f72
--- /dev/null
+++ b/testing/mozbase/mozrunner/tests/test_crash.py
@@ -0,0 +1,37 @@
+#!/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 mock
+
+import mozrunnertest
+
+
+class MozrunnerCrashTestCase(mozrunnertest.MozrunnerTestCase):
+
+ @mock.patch('mozcrash.log_crashes', return_value=2)
+ def test_crash_count_with_logger(self, log_crashes):
+ self.assertEqual(self.runner.crashed, 0)
+ self.assertEqual(self.runner.check_for_crashes(), 2)
+ self.assertEqual(self.runner.crashed, 2)
+ self.assertEqual(self.runner.check_for_crashes(), 2)
+ self.assertEqual(self.runner.crashed, 4)
+
+ log_crashes.return_value = 0
+ self.assertEqual(self.runner.check_for_crashes(), 0)
+ self.assertEqual(self.runner.crashed, 4)
+
+ @mock.patch('mozcrash.check_for_crashes', return_value=2)
+ def test_crash_count_without_logger(self, check_for_crashes):
+ self.runner.logger = None
+
+ self.assertEqual(self.runner.crashed, 0)
+ self.assertEqual(self.runner.check_for_crashes(), 2)
+ self.assertEqual(self.runner.crashed, 2)
+ self.assertEqual(self.runner.check_for_crashes(), 2)
+ self.assertEqual(self.runner.crashed, 4)
+
+ check_for_crashes.return_value = 0
+ self.assertEqual(self.runner.check_for_crashes(), 0)
+ self.assertEqual(self.runner.crashed, 4)
diff --git a/testing/mozbase/mozrunner/tests/test_interactive.py b/testing/mozbase/mozrunner/tests/test_interactive.py
new file mode 100644
index 000000000..fe83bf80e
--- /dev/null
+++ b/testing/mozbase/mozrunner/tests/test_interactive.py
@@ -0,0 +1,53 @@
+#!/usr/bin/env python
+
+import threading
+from time import sleep
+
+import mozrunnertest
+
+
+class RunnerThread(threading.Thread):
+
+ def __init__(self, runner, timeout=10):
+ threading.Thread.__init__(self)
+ self.runner = runner
+ self.timeout = timeout
+
+ def run(self):
+ sleep(self.timeout)
+ self.runner.stop()
+
+
+class MozrunnerInteractiveTestCase(mozrunnertest.MozrunnerTestCase):
+
+ def test_run_interactive(self):
+ """Bug 965183: Run process in interactive mode and call wait()"""
+ pid = self.runner.start(interactive=True)
+ self.pids.append(pid)
+
+ thread = RunnerThread(self.runner, 5)
+ self.threads.append(thread)
+ thread.start()
+
+ # This is a blocking call. So the process should be killed by the thread
+ self.runner.wait()
+ thread.join()
+ self.assertFalse(self.runner.is_running())
+
+ def test_stop_interactive(self):
+ """Bug 965183: Explicitely stop process in interactive mode"""
+ pid = self.runner.start(interactive=True)
+ self.pids.append(pid)
+
+ self.runner.stop()
+
+ def test_wait_after_process_finished(self):
+ """Wait after the process has been stopped should not raise an error"""
+ self.runner.start(interactive=True)
+ sleep(5)
+ self.runner.process_handler.kill()
+
+ returncode = self.runner.wait(1)
+
+ self.assertNotIn(returncode, [None, 0])
+ self.assertIsNotNone(self.runner.process_handler)
diff --git a/testing/mozbase/mozrunner/tests/test_start.py b/testing/mozbase/mozrunner/tests/test_start.py
new file mode 100644
index 000000000..396584e00
--- /dev/null
+++ b/testing/mozbase/mozrunner/tests/test_start.py
@@ -0,0 +1,45 @@
+#!/usr/bin/env python
+
+from time import sleep
+
+import mozrunnertest
+
+
+class MozrunnerStartTestCase(mozrunnertest.MozrunnerTestCase):
+
+ def test_start_process(self):
+ """Start the process and test properties"""
+ self.assertIsNone(self.runner.process_handler)
+
+ self.runner.start()
+
+ self.assertTrue(self.runner.is_running())
+ self.assertIsNotNone(self.runner.process_handler)
+
+ def test_start_process_called_twice(self):
+ """Start the process twice and test that first process is gone"""
+ self.runner.start()
+ # Bug 925480
+ # Make a copy until mozprocess can kill a specific process
+ process_handler = self.runner.process_handler
+
+ self.runner.start()
+
+ try:
+ self.assertNotIn(process_handler.wait(1), [None, 0])
+ finally:
+ process_handler.kill()
+
+ def test_start_with_timeout(self):
+ """Start the process and set a timeout"""
+ self.runner.start(timeout=2)
+ sleep(5)
+
+ self.assertFalse(self.runner.is_running())
+
+ def test_start_with_outputTimeout(self):
+ """Start the process and set a timeout"""
+ self.runner.start(outputTimeout=2)
+ sleep(15)
+
+ self.assertFalse(self.runner.is_running())
diff --git a/testing/mozbase/mozrunner/tests/test_states.py b/testing/mozbase/mozrunner/tests/test_states.py
new file mode 100644
index 000000000..865e12263
--- /dev/null
+++ b/testing/mozbase/mozrunner/tests/test_states.py
@@ -0,0 +1,18 @@
+#!/usr/bin/env python
+
+import mozrunner
+
+import mozrunnertest
+
+
+class MozrunnerStatesTestCase(mozrunnertest.MozrunnerTestCase):
+
+ def test_errors_before_start(self):
+ """Bug 965714: Not started errors before start() is called"""
+
+ def test_returncode():
+ return self.runner.returncode
+
+ self.assertRaises(mozrunner.RunnerNotStartedError, self.runner.is_running)
+ self.assertRaises(mozrunner.RunnerNotStartedError, test_returncode)
+ self.assertRaises(mozrunner.RunnerNotStartedError, self.runner.wait)
diff --git a/testing/mozbase/mozrunner/tests/test_stop.py b/testing/mozbase/mozrunner/tests/test_stop.py
new file mode 100644
index 000000000..102d57a4e
--- /dev/null
+++ b/testing/mozbase/mozrunner/tests/test_stop.py
@@ -0,0 +1,39 @@
+#!/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 mozrunnertest
+
+
+class MozrunnerStopTestCase(mozrunnertest.MozrunnerTestCase):
+
+ def test_stop_process(self):
+ """Stop the process and test properties"""
+ self.runner.start()
+ returncode = self.runner.stop()
+
+ self.assertFalse(self.runner.is_running())
+ self.assertNotIn(returncode, [None, 0])
+ self.assertEqual(self.runner.returncode, returncode)
+ self.assertIsNotNone(self.runner.process_handler)
+
+ self.assertEqual(self.runner.wait(1), returncode)
+
+ def test_stop_before_start(self):
+ """Stop the process before it gets started should not raise an error"""
+ self.runner.stop()
+
+ def test_stop_process_custom_signal(self):
+ """Stop the process via a custom signal and test properties"""
+ self.runner.start()
+ returncode = self.runner.stop(signal.SIGTERM)
+
+ self.assertFalse(self.runner.is_running())
+ self.assertNotIn(returncode, [None, 0])
+ self.assertEqual(self.runner.returncode, returncode)
+ self.assertIsNotNone(self.runner.process_handler)
+
+ self.assertEqual(self.runner.wait(1), returncode)
diff --git a/testing/mozbase/mozrunner/tests/test_threads.py b/testing/mozbase/mozrunner/tests/test_threads.py
new file mode 100644
index 000000000..4b9b4cfc3
--- /dev/null
+++ b/testing/mozbase/mozrunner/tests/test_threads.py
@@ -0,0 +1,73 @@
+#!/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 threading
+from time import sleep
+
+import mozrunnertest
+
+
+class RunnerThread(threading.Thread):
+
+ def __init__(self, runner, do_start, timeout=10):
+ threading.Thread.__init__(self)
+ self.runner = runner
+ self.timeout = timeout
+ self.do_start = do_start
+
+ def run(self):
+ sleep(self.timeout)
+ if self.do_start:
+ self.runner.start()
+ else:
+ self.runner.stop()
+
+
+class MozrunnerThreadsTestCase(mozrunnertest.MozrunnerTestCase):
+
+ def test_process_start_via_thread(self):
+ """Start the runner via a thread"""
+ thread = RunnerThread(self.runner, True, 2)
+ self.threads.append(thread)
+
+ thread.start()
+ thread.join()
+
+ self.assertTrue(self.runner.is_running())
+
+ def test_process_stop_via_multiple_threads(self):
+ """Stop the runner via multiple threads"""
+ self.runner.start()
+ for i in range(5):
+ thread = RunnerThread(self.runner, False, 5)
+ self.threads.append(thread)
+ thread.start()
+
+ # Wait until the process has been stopped by another thread
+ for thread in self.threads:
+ thread.join()
+ returncode = self.runner.wait(2)
+
+ self.assertNotIn(returncode, [None, 0])
+ self.assertEqual(self.runner.returncode, returncode)
+ self.assertIsNotNone(self.runner.process_handler)
+ self.assertEqual(self.runner.wait(10), returncode)
+
+ def test_process_post_stop_via_thread(self):
+ """Stop the runner and try it again with a thread a bit later"""
+ self.runner.start()
+ thread = RunnerThread(self.runner, False, 5)
+ self.threads.append(thread)
+ thread.start()
+
+ # Wait a bit to start the application gets started
+ self.runner.wait(2)
+ returncode = self.runner.stop()
+ thread.join()
+
+ self.assertNotIn(returncode, [None, 0])
+ self.assertEqual(self.runner.returncode, returncode)
+ self.assertIsNotNone(self.runner.process_handler)
+ self.assertEqual(self.runner.wait(10), returncode)
diff --git a/testing/mozbase/mozrunner/tests/test_wait.py b/testing/mozbase/mozrunner/tests/test_wait.py
new file mode 100644
index 000000000..8da1efc3c
--- /dev/null
+++ b/testing/mozbase/mozrunner/tests/test_wait.py
@@ -0,0 +1,29 @@
+#!/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 mozrunnertest
+
+
+class MozrunnerWaitTestCase(mozrunnertest.MozrunnerTestCase):
+
+ def test_wait_while_running(self):
+ """Wait for the process while it is running"""
+ self.runner.start()
+ returncode = self.runner.wait(1)
+
+ self.assertTrue(self.runner.is_running())
+ self.assertEqual(returncode, None)
+ self.assertEqual(self.runner.returncode, returncode)
+ self.assertIsNotNone(self.runner.process_handler)
+
+ def test_wait_after_process_finished(self):
+ """Bug 965714: wait() after stop should not raise an error"""
+ self.runner.start()
+ self.runner.process_handler.kill()
+
+ returncode = self.runner.wait(1)
+
+ self.assertNotIn(returncode, [None, 0])
+ self.assertIsNotNone(self.runner.process_handler)