Bug 1278590 - Create a FennecRunner; r?ahal, gbrown draft
authorMaja Frydrychowicz <mjzffr@gmail.com>
Tue, 07 Jun 2016 11:45:08 -0400
changeset 380099 74a8c8438b0eefd403610a25f50188b32b1ec48f
parent 379746 5f95858f8ddf21ea2271a12810332efd09eff138
child 523647 3f3e05c04014aefb8721c2b2a3da8b8f4e1c5cc2
push id21140
push usermjzffr@gmail.com
push dateMon, 20 Jun 2016 18:57:26 +0000
reviewersahal, gbrown
bugs1278590
milestone50.0a1
Bug 1278590 - Create a FennecRunner; r?ahal, gbrown Add FennecEmulatorRunner (for convenience), FennecRunner, FennecContext and EmulatorAVD. Common behaviour is defined in BaseEmulator and RemoteContext to distinguish from B2G and Fennec specifics. I've tried to decouple ArchContext from B2GContext, as well. The emulator/adb commands in FennecRunner and EmulatorAVD are intended to match the behaviour seen in current Android automation (e.g. mochitest). MozReview-Commit-ID: 1tqD0DStdHR
testing/mozbase/mozrunner/mozrunner/application.py
testing/mozbase/mozrunner/mozrunner/base/__init__.py
testing/mozbase/mozrunner/mozrunner/base/device.py
testing/mozbase/mozrunner/mozrunner/devices/__init__.py
testing/mozbase/mozrunner/mozrunner/devices/base.py
testing/mozbase/mozrunner/mozrunner/devices/emulator.py
testing/mozbase/mozrunner/mozrunner/runners.py
--- a/testing/mozbase/mozrunner/mozrunner/application.py
+++ b/testing/mozbase/mozrunner/mozrunner/application.py
@@ -1,48 +1,139 @@
 # 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
+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 }
+                    '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 B2GContext(object):
-    _bindir = None
+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
-    profile_class = Profile
 
     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
@@ -72,69 +163,36 @@ class B2GContext(object):
 
     @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 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 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 dm(self):
-        if not self._dm:
-            self._dm = DeviceManagerADB(adbPath=self.adb, autoconnect=False, deviceRoot=self.remote_test_root)
-        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
-
-    @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 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)
-
     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'))
--- a/testing/mozbase/mozrunner/mozrunner/base/__init__.py
+++ b/testing/mozbase/mozrunner/mozrunner/base/__init__.py
@@ -1,3 +1,3 @@
 from .runner import BaseRunner
-from .device import DeviceRunner
+from .device import DeviceRunner, FennecRunner
 from .browser import GeckoRuntimeRunner
--- a/testing/mozbase/mozrunner/mozrunner/base/device.py
+++ b/testing/mozbase/mozrunner/mozrunner/base/device.py
@@ -9,17 +9,17 @@ import re
 import signal
 import sys
 import tempfile
 import time
 
 import mozfile
 
 from .runner import BaseRunner
-from ..devices import Emulator
+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',
@@ -60,42 +60,43 @@ class DeviceRunner(BaseRunner):
             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, Emulator) and not self.device.connected:
+        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")
 
-        BaseRunner.start(self, *args, **kwargs)
+        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
@@ -137,22 +138,42 @@ class DeviceRunner(BaseRunner):
             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):
+    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)
+            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):
+
+    @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
+        cmd.extend(['am', 'start', '-a', 'android.activity.MAIN', '-n', app])
+        params = ['-no-remote', '-profile', self.app_ctx.remote_profile]
+        cmd.extend(['--es', 'args', '"%s"' % ' '.join(params)])
+        # Append env variables in the form "--es env0 MOZ_CRASHREPORTER=1"
+        for (count, (k, v)) in enumerate(self._device_env.iteritems()):
+            cmd.extend(["--es", "env" + str(count), k + "=" + v])
+
+        return cmd
--- a/testing/mozbase/mozrunner/mozrunner/devices/__init__.py
+++ b/testing/mozbase/mozrunner/mozrunner/devices/__init__.py
@@ -1,10 +1,10 @@
 # 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 Emulator
+from emulator import BaseEmulator, Emulator, EmulatorAVD
 from base import Device
 
 import emulator_battery
 import emulator_geo
 import emulator_screen
--- a/testing/mozbase/mozrunner/mozrunner/devices/base.py
+++ b/testing/mozbase/mozrunner/mozrunner/devices/base.py
@@ -13,23 +13,24 @@ 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 = logdir
+        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.
         """
--- a/testing/mozbase/mozrunner/mozrunner/devices/emulator.py
+++ b/testing/mozbase/mozrunner/mozrunner/devices/emulator.py
@@ -2,93 +2,102 @@
 # 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 sys
 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):
-        kernel = os.path.join(context.homedir, 'prebuilts', 'qemu-kernel', '%s', '%s')
-        sysdir = os.path.join(context.homedir, 'out', 'target', 'product', '%s')
+    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, 'emulator-x86')
+            self.binary = os.path.join(context.bindir or '', 'emulator-x86')
             self.kernel = kernel % ('x86', 'kernel-qemu')
             self.sysdir = sysdir % 'generic_x86'
-            self.extra_args = []
+        elif avd:
+            self.avd = avd
+            self.extra_args = [
+                '-show-kernel', '-debug',
+                'init,console,gles,memcheck,adbserver,adbclient,adb,avd_config,socket'
+            ]
         else:
-            self.binary = os.path.join(context.bindir, 'emulator')
             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 Emulator(Device):
-    logcat_proc = None
+
+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, arch, resolution=None, sdcard=None, userdata=None,
-                 no_window=None, binary=None, **kwargs):
-        Device.__init__(self, app_ctx, **kwargs)
-
-        self.arch = ArchContext(arch, self.app_ctx, binary=binary)
-        self.resolution = resolution or '320x480'
+    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()
-        self.sdcard = None
-        if sdcard:
-            self.sdcard = self.create_sdcard(sdcard)
-        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
-
+        # 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.
         """
-        qemu_args = [self.arch.binary,
-                     '-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
+        return [self.arch.binary]
 
     def start(self):
         """
         Starts a new emulator.
         """
         if self.proc:
             return
 
@@ -113,75 +122,56 @@ class Emulator(Device):
         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')
+            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 set([d[0] for d in self.dm.devices() if d[1] != 'offline' if d[0].startswith('emulator')])
+        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
 
-        Device.connect(self)
-
-        self.port = int(self.serial[self.serial.rindex('-')+1:])
-        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 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.app_ctx.which('mksdcard')
-        path = tempfile.mktemp(prefix='sdcard', dir=self.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
+        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.
+        Cleans up and kills the emulator, if it was started by mozrunner.
         """
-        Device.cleanup(self)
+        super(BaseEmulator, self).cleanup()
         if self.proc:
             self.proc.kill()
             self.proc = None
 
         # Remove temporary files
-        self.userdata.close()
         shutil.rmtree(self.tmpdir)
 
     def _get_telnet_response(self, command=None):
         output = []
-        assert(self.telnet)
+        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:'):
@@ -192,8 +182,101 @@ class Emulator(Device):
             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()
--- a/testing/mozbase/mozrunner/mozrunner/runners.py
+++ b/testing/mozbase/mozrunner/mozrunner/runners.py
@@ -3,18 +3,18 @@
 # 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
-from .devices import Emulator, Device
+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.
@@ -87,16 +87,53 @@ def B2GDesktopRunner(*args, **kwargs):
         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'
+    :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,
@@ -163,10 +200,11 @@ def B2GDeviceRunner(b2g_home=None,
 
 runners = {
  'default': Runner,
  'b2g_desktop': B2GDesktopRunner,
  'b2g_emulator': B2GEmulatorRunner,
  'b2g_device': B2GDeviceRunner,
  'firefox': FirefoxRunner,
  'thunderbird': ThunderbirdRunner,
+ 'fennec': FennecEmulatorRunner
 }