Bug 1440714 - Convert Android marionette tests to use adb.py; r=bc
authorGeoff Brown <gbrown@mozilla.com>
Thu, 26 Apr 2018 10:43:08 -0600
changeset 471933 a79cd8dc4c1c802cb7768e863584c951eb38f6d6
parent 471932 583940ed5be79dbc03ff7d148194ce28fc5eedd4
child 471934 9d1c3d342559c09b232cb43850eea2d66c7010b0
push id1728
push userjlund@mozilla.com
push dateMon, 18 Jun 2018 21:12:27 +0000
treeherdermozilla-release@c296fde26f5f [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersbc
bugs1440714
milestone61.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
Bug 1440714 - Convert Android marionette tests to use adb.py; r=bc
testing/marionette/client/marionette_driver/geckoinstance.py
testing/marionette/harness/marionette_harness/runner/base.py
testing/mozbase/mozrunner/mozrunner/application.py
testing/mozbase/mozrunner/mozrunner/base/device.py
testing/mozbase/mozrunner/mozrunner/devices/base.py
testing/mozbase/mozrunner/mozrunner/devices/emulator.py
testing/mozbase/mozrunner/mozrunner/runners.py
--- a/testing/marionette/client/marionette_driver/geckoinstance.py
+++ b/testing/marionette/client/marionette_driver/geckoinstance.py
@@ -9,17 +9,16 @@ import sys
 import tempfile
 import time
 import traceback
 
 from copy import deepcopy
 
 import mozversion
 
-from mozdevice import DMError
 from mozprofile import Profile
 from mozrunner import Runner, FennecEmulatorRunner
 from six import reraise
 
 from . import errors
 
 
 # ALL CHANGES TO THIS FILE MUST HAVE REVIEW FROM A MARIONETTE PEER!
@@ -436,26 +435,26 @@ class FennecInstance(GeckoInstance):
             self.runner.start()
         except Exception as e:
             exc, val, tb = sys.exc_info()
             message = "Error possibly due to runner or device args: {}"
             reraise(exc, message.format(e.message), tb)
         # gecko_log comes from logcat when running with device/emulator
         logcat_args = {
             "filterspec": "Gecko",
-            "serial": self.runner.device.dm._deviceSerial
+            "serial": self.runner.device.app_ctx.device_serial
         }
         if self.gecko_log == "-":
             logcat_args["stream"] = sys.stdout
         else:
             logcat_args["logfile"] = self.gecko_log
         self.runner.device.start_logcat(**logcat_args)
 
         # forward marionette port (localhost:2828)
-        self.runner.device.dm.forward(
+        self.runner.device.device.forward(
             local="tcp:{}".format(self.marionette_port),
             remote="tcp:{}".format(self.marionette_port))
 
     def _get_runner_args(self):
         process_args = {
             "processOutputLine": [NullOutput()],
         }
 
@@ -483,20 +482,20 @@ class FennecInstance(GeckoInstance):
         If `clean` is True and the Fennec instance is running in an
         emulator managed by mozrunner, this will stop the emulator.
 
         :param clean: If True, also perform runner cleanup.
         """
         super(FennecInstance, self).close(clean)
         if clean and self.runner and self.runner.device.connected:
             try:
-                self.runner.device.dm.remove_forward(
+                self.runner.device.device.remove_forwards(
                     "tcp:{}".format(self.marionette_port))
                 self.unresponsive_count = 0
-            except DMError:
+            except Exception:
                 self.unresponsive_count += 1
                 traceback.print_exception(*sys.exc_info())
 
 
 class DesktopInstance(GeckoInstance):
     desktop_prefs = {
         # Disable application updates
         "app.update.enabled": False,
--- a/testing/marionette/harness/marionette_harness/runner/base.py
+++ b/testing/marionette/harness/marionette_harness/runner/base.py
@@ -867,17 +867,17 @@ class BaseMarionetteTestRunner(object):
             # backwards compatibility
             self.marionette.baseurl = serve.where_is("/")
 
         self._add_tests(tests)
 
         device_info = None
         if self.marionette.instance and self.emulator:
             try:
-                device_info = self.marionette.instance.runner.device.dm.getInfo()
+                device_info = self.marionette.instance.runner.device.device.get_info()
             except Exception:
                 self.logger.warning('Could not get device info', exc_info=True)
 
         self.marionette.start_session()
         self.logger.info("e10s is {}".format("enabled" if self.is_e10s else "disabled"))
         if self.e10s != self.is_e10s:
             self.cleanup()
             raise AssertionError("BaseMarionetteTestRunner configuration (self.e10s) "
--- a/testing/mozbase/mozrunner/mozrunner/application.py
+++ b/testing/mozbase/mozrunner/mozrunner/application.py
@@ -4,17 +4,17 @@
 
 from __future__ import absolute_import
 
 from abc import ABCMeta, abstractmethod
 from distutils.spawn import find_executable
 import os
 import posixpath
 
-from mozdevice import DeviceManagerADB, DroidADB
+from mozdevice import ADBAndroid
 from mozprofile import (
     Profile,
     ChromeProfile,
     FirefoxProfile,
     ThunderbirdProfile
 )
 
 here = os.path.abspath(os.path.dirname(__file__))
@@ -34,21 +34,20 @@ def get_app_context(appname):
 
 
 class DefaultContext(object):
     profile_class = Profile
 
 
 class RemoteContext(object):
     __metaclass__ = ABCMeta
-    _dm = None
+    device = 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')]
@@ -69,22 +68,16 @@ class RemoteContext(object):
             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)
@@ -99,36 +92,37 @@ class RemoteContext(object):
         """ 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):
+    def __init__(self, app=None, adb_path=None, avd_home=None, device_serial=None):
         self._adb = adb_path
         self.avd_home = avd_home
-        self.dm_class = DroidADB
-        self.remote_process = app or self.dm._packageName
+        self.remote_process = app
+        self.device_serial = device_serial
+        self.device = ADBAndroid(adb=self.adb, device=device_serial)
 
     def stop_application(self):
-        self.dm.stopApplication(self.remote_process)
+        self.device.stop_application(self.remote_process)
 
     @property
     def remote_test_root(self):
         if not self._remote_test_root:
-            self._remote_test_root = self.dm.getDeviceRoot()
+            self._remote_test_root = self.device.test_root
         return self._remote_test_root
 
     @property
     def remote_profiles_ini(self):
         if not self._remote_profiles_ini:
             self._remote_profiles_ini = posixpath.join(
-                self.dm.getAppRoot(self.remote_process),
+                '/data', 'data', self.remote_process,
                 'files', 'mozilla', 'profiles.ini'
             )
         return self._remote_profiles_ini
 
 
 class FirefoxContext(object):
     profile_class = FirefoxProfile
 
--- a/testing/mozbase/mozrunner/mozrunner/base/device.py
+++ b/testing/mozbase/mozrunner/mozrunner/base/device.py
@@ -52,18 +52,18 @@ class DeviceRunner(BaseRunner):
         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])
+        if self.app_ctx.device_serial:
+            cmd.extend(['-s', self.app_ctx.device_serial])
         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:
@@ -80,28 +80,28 @@ class DeviceRunner(BaseRunner):
             raise Exception("Network did not come up when starting device")
 
         pid = BaseRunner.start(self, *args, **kwargs)
 
         timeout = 10  # seconds
         end_time = datetime.datetime.now() + datetime.timedelta(seconds=timeout)
         while not self.is_running() and datetime.datetime.now() < end_time:
             time.sleep(.1)
-        else:
+        if not self.is_running():
             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):
         if self.is_running():
             timeout = 10
 
-            self.app_ctx.dm.killProcess(self.app_ctx.remote_process, sig=sig)
+            self.app_ctx.device.pkill(self.app_ctx.remote_process, sig=sig)
             if self.wait(timeout) is None and sig is not None:
                 print("timed out waiting for '%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
             self.app_ctx.stop_application()
@@ -111,20 +111,20 @@ class DeviceRunner(BaseRunner):
 
     @property
     def returncode(self):
         """The returncode of the remote process.
 
         A value of None indicates the process is still running. Otherwise 0 is
         returned, because there is no known way yet to retrieve the real exit code.
         """
-        if self.app_ctx.dm.processExist(self.app_ctx.remote_process) is None:
-            return 0
+        if self.app_ctx.device.process_exist(self.app_ctx.remote_process):
+            return None
 
-        return None
+        return 0
 
     def wait(self, timeout=None):
         """Wait for the remote process to exit.
 
         :param timeout: if not None, will return after timeout seconds.
 
         :returns: the process return code or None if timeout was reached
                   and the process is still running.
@@ -181,18 +181,18 @@ 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])
+        if self.app_ctx.device_serial:
+            cmd.extend(["-s", self.app_ctx.device_serial])
         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()):
--- a/testing/mozbase/mozrunner/mozrunner/devices/base.py
+++ b/testing/mozbase/mozrunner/mozrunner/devices/base.py
@@ -9,44 +9,44 @@ import datetime
 import os
 import posixpath
 import re
 import shutil
 import subprocess
 import tempfile
 import time
 
-from mozdevice import DMError
+from mozdevice import ADBHost, ADBError
 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.device = self.app_ctx.device
         self.restore = restore
         self.serial = serial
         self.logdir = os.path.abspath(os.path.expanduser(logdir))
         self.added_files = set()
         self.backup_files = set()
 
     @property
     def remote_profiles(self):
         """
         A list of remote profiles on the device.
         """
         remote_ini = self.app_ctx.remote_profiles_ini
-        if not self.dm.fileExists(remote_ini):
+        if not self.device.is_file(remote_ini):
             raise IOError("Remote file '%s' not found" % remote_ini)
 
         local_ini = tempfile.NamedTemporaryFile()
-        self.dm.getFile(remote_ini, local_ini.name)
+        self.device.pull(remote_ini, local_ini.name)
         cfg = ConfigParser()
         cfg.read(local_ini.name)
 
         profiles = []
         for section in cfg.sections():
             if cfg.has_option(section, 'Path'):
                 if cfg.has_option(section, 'IsRelative') and cfg.getint(section, 'IsRelative'):
                     profiles.append(posixpath.join(posixpath.dirname(remote_ini),
@@ -58,100 +58,102 @@ class Device(object):
     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)
+        try:
+            self.device.pull(remote_dump_dir, local_dump_dir)
+        except ADBError as e:
+            # OK if directory not present -- sometimes called before browser start
+            if 'does not exist' not in str(e):
+                raise
         if os.listdir(local_dump_dir):
-            for f in self.dm.listFiles(remote_dump_dir):
-                self.dm.removeFile(posixpath.join(remote_dump_dir, f))
+            self.device.rm(remote_dump_dir, recursive=True)
         return local_dump_dir
 
     def setup_profile(self, profile):
         """
         Copy profile to the device and update the remote profiles.ini
         to point to the new profile.
 
         :param profile: mozprofile object to copy over.
         """
-        self.dm.remount()
+        if self.device.is_dir(self.app_ctx.remote_profile):
+            self.device.rm(self.app_ctx.remote_profile, recursive=True)
 
-        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)
+        self.device.push(profile.profile, self.app_ctx.remote_profile)
 
         timeout = 5  # seconds
         starttime = datetime.datetime.now()
         while datetime.datetime.now() - starttime < datetime.timedelta(seconds=timeout):
-            if self.dm.fileExists(self.app_ctx.remote_profiles_ini):
+            if self.device.is_file(self.app_ctx.remote_profiles_ini):
                 break
             time.sleep(1)
-        else:
+        local_profiles_ini = tempfile.NamedTemporaryFile()
+        if not self.device.is_file(self.app_ctx.remote_profiles_ini):
+            # Unless fennec is already running, and/or remote_profiles_ini is
+            # not inside the remote_profile (deleted above), this is entirely
+            # normal.
             print("timed out waiting for profiles.ini")
-
-        local_profiles_ini = tempfile.NamedTemporaryFile()
-        self.dm.getFile(self.app_ctx.remote_profiles_ini, local_profiles_ini.name)
+        else:
+            self.device.pull(self.app_ctx.remote_profiles_ini, local_profiles_ini.name)
 
         config = ProfileConfigParser()
         config.read(local_profiles_ini.name)
         for section in config.sections():
             if 'Profile' in section:
                 config.set(section, 'IsRelative', 0)
                 config.set(section, 'Path', self.app_ctx.remote_profile)
 
         new_profiles_ini = tempfile.NamedTemporaryFile()
         config.write(open(new_profiles_ini.name, 'w'))
 
         self.backup_file(self.app_ctx.remote_profiles_ini)
-        self.dm.pushFile(new_profiles_ini.name, self.app_ctx.remote_profiles_ini)
+        self.device.push(new_profiles_ini.name, self.app_ctx.remote_profiles_ini)
 
         # Ideally all applications would read the profile the same way, but in practice
         # this isn't true. Perform application specific profile-related setup if necessary.
         if hasattr(self.app_ctx, 'setup_profile'):
             for remote_path in self.app_ctx.remote_backup_files:
                 self.backup_file(remote_path)
             self.app_ctx.setup_profile(profile)
 
     def _get_online_devices(self):
-        return [d[0] for d in self.dm.devices()
-                if d[1] != 'offline'
-                if not d[0].startswith('emulator')]
+        adbhost = ADBHost()
+        devices = adbhost.devices()
+        return [d['device_serial'] for d in devices
+                if d['state'] != 'offline'
+                if not d['device_serial'].startswith('emulator')]
 
     def connect(self):
         """
         Connects to a running device. If no serial was specified in the
         constructor, defaults to the first entry in `adb devices`.
         """
         if self.connected:
             return
 
-        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]
+        online_devices = self._get_online_devices()
+        if not online_devices:
+            raise IOError("No devices connected. Ensure the device is on and "
+                          "remote debugging via adb is enabled in the settings.")
+        self.serial = online_devices[0]
 
-        self.dm._deviceSerial = serial
-        self.dm.connect()
         self.connected = True
 
         if self.logdir:
             # save logcat
-            logcat_log = os.path.join(self.logdir, '%s.log' % serial)
+            logcat_log = os.path.join(self.logdir, '%s.log' % self.serial)
             if os.path.isfile(logcat_log):
                 self._rotate_log(logcat_log)
-            self.logcat_proc = self.start_logcat(serial, logfile=logcat_log)
+            self.logcat_proc = self.start_logcat(self.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 = {}
@@ -162,35 +164,17 @@ class Device(object):
         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()
+        self.device.reboot()
 
     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
@@ -203,50 +187,47 @@ class Device(object):
             time_out += 1
             time.sleep(1)
         return active
 
     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)
+        if self.device.exists(remote_path):
+            self.device.cp(remote_path, '%s.orig' % remote_path, recursive=True)
             self.backup_files.add(remote_path)
         else:
             self.added_files.add(remote_path)
 
     def cleanup(self):
         """
         Cleanup the device.
         """
         if not self.restore:
             return
 
         try:
-            self.dm._verifyDevice()
-        except DMError:
-            return
+            self.device.remount()
+            # Restore the original profile
+            for added_file in self.added_files:
+                self.device.rm(added_file)
 
-        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.device.exists('%s.orig' % backup_file):
+                    self.device.mv('%s.orig' % backup_file, backup_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()
 
-        # 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)
+            # Remove the test profile
+            self.device.rm(self.app_ctx.remote_profile, recursive=True)
+        except Exception as e:
+            print("cleanup aborted: %s" % str(e))
 
     def _rotate_log(self, srclog, index=1):
         """
         Rotate a logfile, by recursively rotating logs further in the sequence,
         deleting the last file if necessary.
         """
         basename = os.path.basename(srclog)
         basename = basename[:-len('.log')]
--- a/testing/mozbase/mozrunner/mozrunner/devices/emulator.py
+++ b/testing/mozbase/mozrunner/mozrunner/devices/emulator.py
@@ -7,16 +7,17 @@ from __future__ import absolute_import
 from telnetlib import Telnet
 import datetime
 import os
 import shutil
 import subprocess
 import tempfile
 import time
 
+from mozdevice import ADBHost
 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
 
@@ -136,30 +137,30 @@ class BaseEmulator(Device):
                 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')]
+        adbhost = ADBHost()
+        return [d['device_serial'] for d in adbhost.devices() if d['state'] != 'offline' if
+                d['device_serial'].startswith('emulator')]
 
     def connect(self):
         """
         Connects to a running device. If no serial was specified in the
         constructor, defaults to the first entry in `adb devices`.
         """
         if self.connected:
             return
 
         super(BaseEmulator, self).connect()
-        serial = self.serial or self.dm._deviceSerial
-        self.port = int(serial[serial.rindex('-') + 1:])
+        self.port = int(self.serial[self.serial.rindex('-') + 1:])
 
     def cleanup(self):
         """
         Cleans up and kills the emulator, if it was started by mozrunner.
         """
         super(BaseEmulator, self).cleanup()
         if self.proc:
             self.proc.kill()
--- a/testing/mozbase/mozrunner/mozrunner/runners.py
+++ b/testing/mozbase/mozrunner/mozrunner/runners.py
@@ -107,21 +107,21 @@ def FennecEmulatorRunner(avd='mozemulato
     :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)
+                                                  avd_home=avd_home,
+                                                  device_serial=serial)
     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)
 
 
 runners = {
     'chrome': ChromeRunner,