diff options
author | Matt A. Tobin <mattatobin@localhost.localdomain> | 2018-02-02 04:16:08 -0500 |
---|---|---|
committer | Matt A. Tobin <mattatobin@localhost.localdomain> | 2018-02-02 04:16:08 -0500 |
commit | 5f8de423f190bbb79a62f804151bc24824fa32d8 (patch) | |
tree | 10027f336435511475e392454359edea8e25895d /testing/mozbase/mozrunner | |
parent | 49ee0794b5d912db1f95dce6eb52d781dc210db5 (diff) | |
download | UXP-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')
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 Binary files differnew file mode 100644 index 000000000..d3bcbfbee --- /dev/null +++ b/testing/mozbase/mozrunner/mozrunner/resources/metrotestharness.exe 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) |