Bug 1103196 - Add HTTPS fixture server for Marionette. r=automatedtester, r=maja_zf, r=whimboo, a=test-only
authorAndreas Tolfsen <ato@mozilla.com>
Fri, 02 Dec 2016 07:44:00 -0500
changeset 352820 c282ad416a40a47603ad4fa16f6bca5ab8589ae6
parent 352819 5dc1f6e2e083afa300538d6e3c8e1dd772ba85a8
child 352821 fd42bc259e210733cd90841768554fc53f39c0e5
push id6795
push userjlund@mozilla.com
push dateMon, 23 Jan 2017 14:19:46 +0000
treeherdermozilla-esr52@76101b503191 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersautomatedtester, maja_zf, whimboo, test-only
bugs1103196
milestone52.0a2
Bug 1103196 - Add HTTPS fixture server for Marionette. r=automatedtester, r=maja_zf, r=whimboo, a=test-only This patch makes testing/marionette/harness/marionette/runner/httpd.py capable of spinning up an HTTPS server with self-signed certificate. It also introduces testing/marionette/harness/marionette/runner/serve.py that is capable of managing the HTTP- and HTTPS fixture servers as subprocesses, allowing them to operate independently. serve.py uses inter-process communication based on channels (from `multiprocessing.Pipe`) to query methods on the httpd served in each process, which is used to get absolute URLs from the main process. This is useful as servers can delegate port allocation to the system socket service by atomically binding to port 0, guaranteeing that the chosen port is uncontested. It is also used to perform synchronous and graceful shutdown of the httpd. MozReview-Commit-ID: 9OlW6F1w0AN
testing/marionette/harness/MANIFEST.in
testing/marionette/harness/marionette/marionette_test/testcases.py
testing/marionette/harness/marionette/runner/base.py
testing/marionette/harness/marionette/runner/httpd.py
testing/marionette/harness/marionette/runner/serve.py
testing/marionette/harness/marionette/runner/test.cert
testing/marionette/harness/marionette/runner/test.key
testing/marionette/harness/marionette/tests/harness_unit/test_httpd.py
testing/marionette/harness/marionette/tests/harness_unit/test_marionette_runner.py
testing/marionette/harness/marionette/tests/harness_unit/test_serve.py
testing/marionette/harness/marionette/tests/unit/test_httpd.py
testing/marionette/harness/marionette/tests/unit/unit-tests.ini
testing/marionette/harness/requirements.txt
--- a/testing/marionette/harness/MANIFEST.in
+++ b/testing/marionette/harness/MANIFEST.in
@@ -1,6 +1,8 @@
 recursive-include marionette/touch *.js
 recursive-include marionette/www *
 recursive-include marionette/chrome *
 recursive-include marionette/runner/mixins/resources *
 exclude MANIFEST.in
 include requirements.txt
+include marionette/runner/test.cert
+include marionette/runner/test.key
--- a/testing/marionette/harness/marionette/marionette_test/testcases.py
+++ b/testing/marionette/harness/marionette/marionette_test/testcases.py
@@ -69,18 +69,22 @@ class JSTest:
 
 class CommonTestCase(unittest.TestCase):
 
     __metaclass__ = MetaParameterized
     match_re = None
     failureException = AssertionError
     pydebugger = None
 
-    def __init__(self, methodName, **kwargs):
+    def __init__(self, methodName, marionette_weakref, fixtures, **kwargs):
         super(CommonTestCase, self).__init__(methodName)
+        self.methodName = methodName
+
+        self._marionette_weakref = marionette_weakref
+        self.fixtures = fixtures
 
         self.loglines = []
         self.duration = 0
         self.start_time = 0
         self.expected = kwargs.pop('expected', 'pass')
         self.logger = get_default_logger()
 
     def _enter_pm(self):
@@ -220,17 +224,17 @@ class CommonTestCase(unittest.TestCase):
         """
         if not cls.match_re:
             return False
         m = cls.match_re.match(filename)
         return m is not None
 
     @classmethod
     def add_tests_to_suite(cls, mod_name, filepath, suite, testloader, marionette,
-                           httpd, testvars):
+                           fixtures, testvars, **kwargs):
         """Add all the tests in the specified file to the specified suite."""
         raise NotImplementedError
 
     @property
     def test_name(self):
         if hasattr(self, 'jsFile'):
             return os.path.basename(self.jsFile)
         else:
@@ -247,17 +251,16 @@ class CommonTestCase(unittest.TestCase):
 
     def setUp(self):
         # Convert the marionette weakref to an object, just for the
         # duration of the test; this is deleted in tearDown() to prevent
         # a persistent circular reference which in turn would prevent
         # proper garbage collection.
         self.start_time = time.time()
         self.marionette = self._marionette_weakref()
-        self.httpd = self._httpd_weakref()
         if self.marionette.session is None:
             self.marionette.start_session()
         self.marionette.timeout.reset()
 
         super(CommonTestCase, self).setUp()
 
     def cleanTest(self):
         self._deleteSession()
@@ -416,31 +419,27 @@ if (!testUtils.hasOwnProperty("specialPo
                 raise
         self.marionette.test_name = original_test_name
 
 
 class MarionetteTestCase(CommonTestCase):
 
     match_re = re.compile(r"test_(.*)\.py$")
 
-    def __init__(self, marionette_weakref, httpd_weakref, methodName='runTest',
+    def __init__(self, marionette_weakref, fixtures, methodName='runTest',
                  filepath='', **kwargs):
-        self._marionette_weakref = marionette_weakref
-        self._httpd_weakref = httpd_weakref
-        self.methodName = methodName
         self.filepath = filepath
         self.testvars = kwargs.pop('testvars', None)
 
-        self.marionette = None
-
-        super(MarionetteTestCase, self).__init__(methodName, **kwargs)
+        super(MarionetteTestCase, self).__init__(
+            methodName, marionette_weakref=marionette_weakref, fixtures=fixtures, **kwargs)
 
     @classmethod
     def add_tests_to_suite(cls, mod_name, filepath, suite, testloader, marionette,
-                           httpd, testvars, **kwargs):
+                           fixtures, testvars, **kwargs):
         # since we use imp.load_source to load test modules, if a module
         # is loaded with the same name as another one the module would just be
         # reloaded.
         #
         # We may end up by finding too many test in a module then since
         # reload() only update the module dict (so old keys are still there!)
         # see https://docs.python.org/2/library/functions.html#reload
         #
@@ -454,17 +453,17 @@ class MarionetteTestCase(CommonTestCase)
 
         for name in dir(test_mod):
             obj = getattr(test_mod, name)
             if (isinstance(obj, (type, types.ClassType)) and
                     issubclass(obj, unittest.TestCase)):
                 testnames = testloader.getTestCaseNames(obj)
                 for testname in testnames:
                     suite.addTest(obj(weakref.ref(marionette),
-                                      weakref.ref(httpd),
+                                      fixtures,
                                       methodName=testname,
                                       filepath=filepath,
                                       testvars=testvars,
                                       **kwargs))
 
     def setUp(self):
         super(MarionetteTestCase, self).setUp()
         self.marionette.test_name = self.test_name
--- a/testing/marionette/harness/marionette/runner/base.py
+++ b/testing/marionette/harness/marionette/runner/base.py
@@ -6,34 +6,30 @@ import json
 import os
 import random
 import re
 import socket
 import sys
 import time
 import traceback
 import unittest
-import warnings
-
 from argparse import ArgumentParser
 from copy import deepcopy
 
 import mozinfo
-import moznetwork
 import mozprofile
+from marionette_driver.marionette import Marionette
+
 import mozversion
-
+import serve
 from manifestparser import TestManifest
 from manifestparser.filters import tags
-from marionette_driver.marionette import Marionette
-from moztest.adapters.unit import StructuredTestRunner, StructuredTestResult
-from moztest.results import TestResultCollection, TestResult, relevant_line
-
-import httpd
-
+from moztest.adapters.unit import StructuredTestResult, StructuredTestRunner
+from moztest.results import TestResult, TestResultCollection, relevant_line
+from serve import iter_proc, iter_url
 
 here = os.path.abspath(os.path.dirname(__file__))
 
 
 def update_mozinfo(path=None):
     """Walk up directories to find mozinfo.json and update the info."""
     path = path or here
     dirs = set()
@@ -484,16 +480,21 @@ class RemoteMarionetteArguments(object):
         [['--package'],
          {'help': 'Name of Android package, e.g. org.mozilla.fennec',
           'dest': 'package_name',
           }],
 
     ]
 
 
+class Fixtures(object):
+    def where_is(self, uri, on="http"):
+        return serve.where_is(uri, on)
+
+
 class BaseMarionetteTestRunner(object):
 
     textrunnerclass = MarionetteTextTestRunner
     driverclass = Marionette
 
     def __init__(self, address=None,
                  app=None, app_args=None, binary=None, profile=None,
                  logger=None, logdir=None,
@@ -501,34 +502,34 @@ class BaseMarionetteTestRunner(object):
                  symbols_path=None,
                  shuffle=False, shuffle_seed=random.randint(0, sys.maxint), this_chunk=1,
                  total_chunks=1,
                  server_root=None, gecko_log=None, result_callbacks=None,
                  prefs=None, test_tags=None,
                  socket_timeout=BaseMarionetteArguments.socket_timeout_default,
                  startup_timeout=None, addons=None, workspace=None,
                  verbose=0, e10s=True, emulator=False, **kwargs):
-
         self._appinfo = None
         self._appName = None
         self._capabilities = None
         self._filename_pattern = None
         self._version_info = {}
 
+        self.fixture_servers = {}
+        self.fixtures = Fixtures()
         self.extra_kwargs = kwargs
         self.test_kwargs = deepcopy(kwargs)
         self.address = address
         self.app = app
         self.app_args = app_args or []
         self.bin = binary
         self.emulator = emulator
         self.profile = profile
         self.addons = addons
         self.logger = logger
-        self.httpd = None
         self.marionette = None
         self.logdir = logdir
         self.repeat = repeat
         self.symbols_path = symbols_path
         self.socket_timeout = socket_timeout
         self.shuffle = shuffle
         self.shuffle_seed = shuffle_seed
         self.server_root = server_root
@@ -667,17 +668,16 @@ class BaseMarionetteTestRunner(object):
 
     @property
     def bin(self):
         return self._bin
 
     @bin.setter
     def bin(self, path):
         """Set binary and reset parts of runner accordingly.
-
         Intended use: to change binary between calls to run_tests
         """
         self._bin = path
         self.tests = []
         self.cleanup()
 
     @property
     def version_info(self):
@@ -770,40 +770,16 @@ class BaseMarionetteTestRunner(object):
             traceback.print_exc()
         return crash
 
     def _initialize_test_run(self, tests):
         assert len(tests) > 0
         assert len(self.test_handlers) > 0
         self.reset_test_stats()
 
-    def _start_marionette(self):
-        need_external_ip = True
-        if not self.marionette:
-            self.marionette = self.driverclass(**self._build_kwargs())
-            # if we're working against a desktop version, we usually don't need
-            # an external ip
-            if self.appName != 'fennec':
-                need_external_ip = False
-        self.logger.info('Initial Profile Destination is '
-                         '"{}"'.format(self.marionette.profile_path))
-        return need_external_ip
-
-    def _set_baseurl(self, need_external_ip):
-        # Gaia sets server_root and that means we shouldn't spin up our own httpd
-        if not self.httpd:
-            if self.server_root is None or os.path.isdir(self.server_root):
-                self.logger.info("starting httpd")
-                self.start_httpd(need_external_ip)
-                self.marionette.baseurl = self.httpd.get_url()
-                self.logger.info("running httpd on {}".format(self.marionette.baseurl))
-            else:
-                self.marionette.baseurl = self.server_root
-                self.logger.info("using remote content from {}".format(self.marionette.baseurl))
-
     def _add_tests(self, tests):
         for test in tests:
             self.add_test(test)
 
         invalid_tests = [t['filepath'] for t in self.tests
                          if not self._is_filename_valid(t['filepath'])]
         if invalid_tests:
             raise Exception("Test file names must be of the form "
@@ -822,29 +798,43 @@ class BaseMarionetteTestRunner(object):
                                  'SKIP',
                                  message=test['disabled'])
             self.todo += 1
 
     def run_tests(self, tests):
         start_time = time.time()
         self._initialize_test_run(tests)
 
-        need_external_ip = self._start_marionette()
-        self._set_baseurl(need_external_ip)
+        if self.marionette is None:
+            self.marionette = self.driverclass(**self._build_kwargs())
+            self.logger.info("Profile path is %s" % self.marionette.profile_path)
+
+        if len(self.fixture_servers) == 0 or \
+                any(not server.is_alive for _, server in self.fixture_servers):
+            self.logger.info("Starting fixture servers")
+            self.fixture_servers = self.start_fixture_servers()
+            for url in iter_url(self.fixture_servers):
+                self.logger.info("Fixture server listening on %s" % url)
+
+            # 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()
             except Exception:
-                self.logger.warning('Could not get device info.')
+                self.logger.warning('Could not get device info', exc_info=True)
 
-        self.logger.info("running with e10s: {}".format(self.e10s))
+        if self.e10s:
+            self.logger.info("e10s is enabled")
+        else:
+            self.logger.info("e10s is disabled")
 
         self.logger.suite_start(self.tests,
                                 version_info=self.version_info,
                                 device_info=device_info)
 
         self._log_skipped_tests()
 
         interrupted = None
@@ -870,19 +860,18 @@ class BaseMarionetteTestRunner(object):
         try:
             self._print_summary(tests)
             self.record_crash()
             self.elapsedtime = time.time() - start_time
 
             for run_tests in self.mixin_run_tests:
                 run_tests(tests)
             if self.shuffle:
-                self.logger.info("Using seed where seed is:{}".format(self.shuffle_seed))
+                self.logger.info("Using shuffle seed: %d" % self.shuffle_seed)
 
-            self.logger.info('mode: {}'.format('e10s' if self.e10s else 'non-e10s'))
             self.logger.suite_end()
         except:
             # raise only the exception if we were not interrupted
             if not interrupted:
                 raise
         finally:
             self.cleanup()
 
@@ -904,29 +893,19 @@ class BaseMarionetteTestRunner(object):
         else:
             self.logger.info('todo: {0} (skipped: {1})'.format(self.todo, self.skipped))
 
         if self.failed > 0:
             self.logger.info('\nFAILED TESTS\n-------')
             for failed_test in self.failures:
                 self.logger.info('{}'.format(failed_test[0]))
 
-    def start_httpd(self, need_external_ip):
-        warnings.warn("start_httpd has been deprecated in favour of create_httpd",
-                      DeprecationWarning)
-        self.httpd = self.create_httpd(need_external_ip)
-
-    def create_httpd(self, need_external_ip):
-        host = "127.0.0.1"
-        if need_external_ip:
-            host = moznetwork.get_ip()
+    def start_fixture_servers(self):
         root = self.server_root or os.path.join(os.path.dirname(here), "www")
-        rv = httpd.FixtureServer(root, host=host)
-        rv.start()
-        return rv
+        return serve.start(root)
 
     def add_test(self, test, expected='pass'):
         filepath = os.path.abspath(test)
 
         if os.path.isdir(filepath):
             for root, dirs, files in os.walk(filepath):
                 for filename in files:
                     if filename.endswith('.ini'):
@@ -976,29 +955,28 @@ class BaseMarionetteTestRunner(object):
                 file_ext = os.path.splitext(os.path.split(i['path'])[-1])[-1]
 
                 self.add_test(i["path"], i["expected"])
             return
 
         self.tests.append({'filepath': filepath, 'expected': expected})
 
     def run_test(self, filepath, expected):
-
         testloader = unittest.TestLoader()
         suite = unittest.TestSuite()
         self.test_kwargs['expected'] = expected
         mod_name = os.path.splitext(os.path.split(filepath)[-1])[0]
         for handler in self.test_handlers:
             if handler.match(os.path.basename(filepath)):
                 handler.add_tests_to_suite(mod_name,
                                            filepath,
                                            suite,
                                            testloader,
                                            self.marionette,
-                                           self.httpd,
+                                           self.fixtures,
                                            self.testvars,
                                            **self.test_kwargs)
                 break
 
         if suite.countTestCases():
             runner = self.textrunnerclass(logger=self.logger,
                                           marionette=self.marionette,
                                           capabilities=self.capabilities,
@@ -1054,21 +1032,22 @@ class BaseMarionetteTestRunner(object):
                              'total of {3})'.format(self.this_chunk, self.total_chunks,
                                                     len(chunks[self.this_chunk - 1]),
                                                     len(self.tests)))
             self.tests = chunks[self.this_chunk - 1]
 
         self.run_test_set(self.tests)
 
     def cleanup(self):
-        if hasattr(self, 'httpd') and self.httpd:
-            self.httpd.stop()
-            self.httpd = None
+        for proc in iter_proc(self.fixture_servers):
+            proc.stop()
+            proc.kill()
+        self.fixture_servers = {}
 
         if hasattr(self, 'marionette') and self.marionette:
-            if self.marionette.instance:
+            if self.marionette.instance is not None:
                 self.marionette.instance.close()
                 self.marionette.instance = None
 
             self.marionette.cleanup()
             self.marionette = None
 
     __del__ = cleanup
--- a/testing/marionette/harness/marionette/runner/httpd.py
+++ b/testing/marionette/harness/marionette/runner/httpd.py
@@ -1,82 +1,142 @@
+#!/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/.
 
+"""Specialisation of wptserver.server.WebTestHttpd for testing
+Marionette.
+
+"""
+
+import argparse
 import os
+import select
+import sys
 import time
+import urlparse
 
 from wptserve import server, handlers, routes as default_routes
 
 
-class FixtureServer(object):
-
-    def __init__(self, root, host="127.0.0.1", port=0):
-        if not os.path.isdir(root):
-            raise IOError("Server root is not a valid path: {}".format(root))
-        self.root = root
-        self.host = host
-        self.port = port
-        self._server = None
-
-    def start(self, block=False):
-        if self.alive:
-            return
-        routes = [("POST", "/file_upload", upload_handler),
-                  ("GET", "/slow", slow_loading_document)]
-        routes.extend(default_routes.routes)
-        self._server = server.WebTestHttpd(
-            port=self.port,
-            doc_root=self.root,
-            routes=routes,
-            host=self.host,
-        )
-        self._server.start(block=block)
-        self.port = self._server.httpd.server_port
-        self.base_url = self.get_url()
-
-    def stop(self):
-        if not self.alive:
-            return
-        self._server.stop()
-        self._server = None
-
-    @property
-    def alive(self):
-        return self._server is not None
-
-    def get_url(self, path="/"):
-        if not self.alive:
-            raise Exception("Server not started")
-        return self._server.get_url(path)
-
-    @property
-    def router(self):
-        return self._server.router
-
-    @property
-    def routes(self):
-        return self._server.router.routes
+here = os.path.abspath(os.path.dirname(__file__))
+default_doc_root = os.path.join(os.path.dirname(here), "www")
+default_ssl_cert = os.path.join(here, "test.cert")
+default_ssl_key = os.path.join(here, "test.key")
 
 
 @handlers.handler
 def upload_handler(request, response):
     return 200, [], [request.headers.get("Content-Type")] or []
 
 
 @handlers.handler
 def slow_loading_document(request, response):
     time.sleep(5)
     return """<!doctype html>
 <title>ok</title>
 <p>ok"""
 
 
+class NotAliveError(Exception):
+    """Occurs when attempting to run a function that requires the HTTPD
+    to have been started, and it has not.
+
+    """
+    pass
+
+
+class FixtureServer(object):
+
+    def __init__(self, doc_root, url="http://127.0.0.1:0", use_ssl=False,
+                 ssl_cert=None, ssl_key=None):
+        if not os.path.isdir(doc_root):
+            raise ValueError("Server root is not a directory: %s" % doc_root)
+
+        url = urlparse.urlparse(url)
+        if url.scheme is None:
+            raise ValueError("Server scheme not provided")
+
+        scheme, host, port = url.scheme, url.hostname, url.port
+        if host is None:
+            host = "127.0.0.1"
+        if port is None:
+            port = 0
+
+        routes = [("POST", "/file_upload", upload_handler),
+                  ("GET", "/slow", slow_loading_document)]
+        routes.extend(default_routes.routes)
+
+        self._httpd = server.WebTestHttpd(host=host,
+                                          port=port,
+                                          bind_hostname=True,
+                                          doc_root=doc_root,
+                                          routes=routes,
+                                          use_ssl=True if scheme == "https" else False,
+                                          certificate=ssl_cert,
+                                          key_file=ssl_key)
+
+    def start(self, block=False):
+        if self.is_alive:
+            return
+        self._httpd.start(block=block)
+
+    def wait(self):
+        if not self.is_alive:
+            return
+        try:
+            select.select([], [], [])
+        except KeyboardInterrupt:
+            self.stop()
+
+    def stop(self):
+        if not self.is_alive:
+            return
+        self._httpd.stop()
+
+    def get_url(self, path):
+        if not self.is_alive:
+            raise NotAliveError()
+        return self._httpd.get_url(path)
+
+    @property
+    def doc_root(self):
+        return self._httpd.router.doc_root
+
+    @property
+    def router(self):
+        return self._httpd.router
+
+    @property
+    def routes(self):
+        return self._httpd.router.routes
+
+    @property
+    def is_alive(self):
+        return self._httpd.started
+
+
 if __name__ == "__main__":
-    here = os.path.abspath(os.path.dirname(__file__))
-    doc_root = os.path.join(os.path.dirname(here), "www")
-    httpd = FixtureServer(doc_root, port=2829)
-    print "Started fixture server on http://{0}:{1}/".format(httpd.host, httpd.port)
-    try:
-        httpd.start(True)
-    except KeyboardInterrupt:
-        pass
+    parser = argparse.ArgumentParser(
+        description="Specialised HTTP server for testing Marionette.")
+    parser.add_argument("url", help="""
+service address including scheme, hostname, port, and prefix for document root,
+e.g. \"https://0.0.0.0:0/base/\"""")
+    parser.add_argument(
+        "-r", dest="doc_root", default=default_doc_root,
+        help="path to document root (default %(default)s)")
+    parser.add_argument(
+        "-c", dest="ssl_cert", default=default_ssl_cert,
+        help="path to SSL certificate (default %(default)s)")
+    parser.add_argument(
+        "-k", dest="ssl_key", default=default_ssl_key,
+        help="path to SSL certificate key (default %(default)s)")
+    args = parser.parse_args()
+
+    httpd = FixtureServer(args.doc_root, args.url,
+                          ssl_cert=args.ssl_cert,
+                          ssl_key=args.ssl_key)
+    httpd.start()
+    print >>sys.stderr, "%s: started fixture server on %s" % \
+        (sys.argv[0], httpd.get_url("/"))
+    httpd.wait()
new file mode 100755
--- /dev/null
+++ b/testing/marionette/harness/marionette/runner/serve.py
@@ -0,0 +1,227 @@
+#!/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/.
+
+"""Spawns necessary HTTP servers for testing Marionette in child
+processes.
+
+"""
+
+import argparse
+import multiprocessing
+import os
+import sys
+from collections import defaultdict
+
+import moznetwork
+
+import httpd
+
+__all__ = ["default_doc_root",
+           "iter_proc",
+           "iter_url",
+           "registered_servers",
+           "servers",
+           "start",
+           "where_is"]
+here = os.path.abspath(os.path.dirname(__file__))
+
+
+class BlockingChannel(object):
+
+    def __init__(self, channel):
+        self.chan = channel
+        self.lock = multiprocessing.Lock()
+
+    def call(self, func, args=()):
+        self.send((func, args))
+        return self.recv()
+
+    def send(self, *args):
+        try:
+            self.lock.acquire()
+            self.chan.send(args)
+        finally:
+            self.lock.release()
+
+    def recv(self):
+        try:
+            self.lock.acquire()
+            payload = self.chan.recv()
+            if isinstance(payload, tuple) and len(payload) == 1:
+                return payload[0]
+            return payload
+        except KeyboardInterrupt:
+            return ("stop", ())
+        finally:
+            self.lock.release()
+
+
+class ServerProxy(multiprocessing.Process, BlockingChannel):
+
+    def __init__(self, channel, init_func, *init_args, **init_kwargs):
+        multiprocessing.Process.__init__(self)
+        BlockingChannel.__init__(self, channel)
+        self.init_func = init_func
+        self.init_args = init_args
+        self.init_kwargs = init_kwargs
+
+    def run(self):
+        server = self.init_func(*self.init_args, **self.init_kwargs)
+        server.start(block=False)
+
+        try:
+            while True:
+                # ["func", ("arg", ...)]
+                # ["prop", ()]
+                sattr, fargs = self.recv()
+                attr = getattr(server, sattr)
+
+                # apply fargs to attr if it is a function
+                if callable(attr):
+                    rv = attr(*fargs)
+
+                # otherwise attr is a property
+                else:
+                    rv = attr
+
+                self.send(rv)
+
+                if sattr == "stop":
+                    return
+
+        except KeyboardInterrupt:
+            server.stop()
+
+
+class ServerProc(BlockingChannel):
+
+    def __init__(self, init_func):
+        self._init_func = init_func
+        self.proc = None
+
+        parent_chan, self.child_chan = multiprocessing.Pipe()
+        BlockingChannel.__init__(self, parent_chan)
+
+    def start(self, doc_root, ssl_config, **kwargs):
+        self.proc = ServerProxy(
+            self.child_chan, self._init_func, doc_root, ssl_config, **kwargs)
+        self.proc.daemon = True
+        self.proc.start()
+
+    def get_url(self, url):
+        return self.call("get_url", (url,))
+
+    @property
+    def doc_root(self):
+        return self.call("doc_root", ())
+
+    def stop(self):
+        self.call("stop")
+        if not self.is_alive:
+            return
+        self.proc.join()
+
+    def kill(self):
+        if not self.is_alive:
+            return
+        self.proc.terminate()
+        self.proc.join(0)
+
+    @property
+    def is_alive(self):
+        if self.proc is not None:
+            return self.proc.is_alive()
+        return False
+
+
+def http_server(doc_root, ssl_config, **kwargs):
+    return httpd.FixtureServer(doc_root, url="http://%s:0/" % moznetwork.get_ip())
+
+
+def https_server(doc_root, ssl_config, **kwargs):
+    return httpd.FixtureServer(doc_root,
+                               url="https://%s:0/" % moznetwork.get_ip(),
+                               ssl_key=ssl_config["key_path"],
+                               ssl_cert=ssl_config["cert_path"])
+
+
+def start_servers(doc_root, ssl_config, **kwargs):
+    servers = defaultdict()
+    for schema, builder_fn in registered_servers:
+        proc = ServerProc(builder_fn)
+        proc.start(doc_root, ssl_config, **kwargs)
+        servers[schema] = (proc.get_url("/"), proc)
+    return servers
+
+
+def start(doc_root=None, **kwargs):
+    """Start all relevant test servers.
+
+    If no `doc_root` is given the default
+    testing/marionette/harness/marionette/www directory will be used.
+
+    Additional keyword arguments can be given which will be passed on
+    to the individual ``FixtureServer``'s in httpd.py.
+
+    """
+    doc_root = doc_root or default_doc_root
+    ssl_config = {"cert_path": httpd.default_ssl_cert,
+                  "key_path": httpd.default_ssl_key}
+
+    global servers
+    servers = start_servers(doc_root, ssl_config, **kwargs)
+
+    return servers
+
+
+def where_is(uri, on="http"):
+    """Returns the full URL, including scheme, hostname, and port, for
+    a fixture resource from the server associated with the ``on`` key.
+    It will by default look for the resource in the "http" server.
+
+    """
+    return servers.get(on)[1].get_url(uri)
+
+
+def iter_proc(servers):
+    for _, (_, proc) in servers.iteritems():
+        yield proc
+
+
+def iter_url(servers):
+    for _, (url, _) in servers.iteritems():
+        yield url
+
+
+default_doc_root = os.path.join(os.path.dirname(here), "www")
+registered_servers = [("http", http_server),
+                      ("https", https_server)]
+servers = defaultdict()
+
+
+def main(args):
+    global servers
+
+    parser = argparse.ArgumentParser()
+    parser.add_argument("-r", dest="doc_root",
+                        help="Path to document root.  Overrides default.")
+    args = parser.parse_args()
+
+    servers = start(args.doc_root)
+    for url in iter_url(servers):
+        print >>sys.stderr, "%s: listening on %s" % (sys.argv[0], url)
+
+    try:
+        while any(proc.is_alive for proc in iter_proc(servers)):
+            for proc in iter_proc(servers):
+                proc.proc.join(1)
+    except KeyboardInterrupt:
+        for proc in iter_proc(servers):
+            proc.kill()
+
+
+if __name__ == "__main__":
+    main(sys.argv[1:])
new file mode 100644
--- /dev/null
+++ b/testing/marionette/harness/marionette/runner/test.cert
@@ -0,0 +1,86 @@
+Certificate:
+    Data:
+        Version: 3 (0x2)
+        Serial Number: 2 (0x2)
+    Signature Algorithm: sha256WithRSAEncryption
+        Issuer: CN=web-platform-tests
+        Validity
+            Not Before: Dec 22 12:09:16 2014 GMT
+            Not After : Dec 21 12:09:16 2024 GMT
+        Subject: CN=web-platform.test
+        Subject Public Key Info:
+            Public Key Algorithm: rsaEncryption
+                Public-Key: (2048 bit)
+                Modulus:
+                    00:b3:84:d6:8b:01:59:18:85:d1:dc:32:df:38:f7:
+                    90:85:1b:3e:a5:5e:81:3e:2f:fc:3a:5f:7f:77:ef:
+                    23:bb:3a:88:27:0f:be:25:46:cd:63:7d:cb:95:d8:
+                    a5:50:10:d2:a2:d2:b7:97:d1:0d:6c:fb:f9:05:e8:
+                    6f:a8:4b:bd:95:67:9e:7b:94:58:a9:6d:93:fd:e0:
+                    12:c5:cd:b4:8a:64:52:31:5f:0e:e3:89:84:71:da:
+                    98:dd:4b:ec:02:25:a5:7d:35:fe:63:da:b3:ac:ec:
+                    a5:46:0f:0d:64:23:5c:6d:f3:ec:cc:28:63:23:c0:
+                    4b:9a:ec:8f:c1:ee:b1:a2:3e:72:4d:70:b5:09:c1:
+                    eb:b4:10:55:3c:8b:ea:1b:94:7e:4b:74:e6:f4:9f:
+                    4f:a6:45:30:b5:f0:b8:b4:d1:59:50:65:0a:86:53:
+                    ea:4c:9f:9e:f4:58:6c:31:f5:17:3a:6f:57:8b:cb:
+                    5f:f0:28:0b:45:92:8d:30:20:49:ff:52:e6:2c:cb:
+                    18:9a:d7:e6:ee:3e:4f:34:35:15:13:c5:02:da:c5:
+                    5f:be:fb:5b:ce:8d:bf:b5:35:76:3c:7c:e6:9c:3b:
+                    26:87:4d:8d:80:e6:16:c6:27:f2:50:49:b6:72:74:
+                    43:49:49:44:38:bb:78:43:23:ee:16:3e:d9:62:e6:
+                    a5:d7
+                Exponent: 65537 (0x10001)
+        X509v3 extensions:
+            X509v3 Basic Constraints: 
+                CA:FALSE
+            X509v3 Subject Key Identifier: 
+                2D:98:A3:99:39:1C:FE:E9:9A:6D:17:94:D2:3A:96:EE:C8:9E:04:22
+            X509v3 Authority Key Identifier: 
+                keyid:6A:AB:53:64:92:36:87:23:34:B3:1D:6F:85:4B:F5:DF:5A:5C:74:8F
+
+            X509v3 Key Usage: 
+                Digital Signature, Non Repudiation, Key Encipherment
+            X509v3 Extended Key Usage: 
+                TLS Web Server Authentication
+            X509v3 Subject Alternative Name: 
+                DNS:web-platform.test, DNS:www.web-platform.test, DNS:xn--n8j6ds53lwwkrqhv28a.web-platform.test, DNS:xn--lve-6lad.web-platform.test, DNS:www2.web-platform.test, DNS:www1.web-platform.test
+    Signature Algorithm: sha256WithRSAEncryption
+         33:db:f7:f0:f6:92:16:4f:2d:42:bc:b8:aa:e6:ab:5e:f9:b9:
+         b0:48:ae:b5:8d:cc:02:7b:e9:6f:4e:75:f7:17:a0:5e:7b:87:
+         06:49:48:83:c5:bb:ca:95:07:37:0e:5d:e3:97:de:9e:0c:a4:
+         82:30:11:81:49:5d:50:29:72:92:a5:ca:17:b1:7c:f1:32:11:
+         17:57:e6:59:c1:ac:e3:3b:26:d2:94:97:50:6a:b9:54:88:84:
+         9b:6f:b1:06:f5:80:04:22:10:14:b1:f5:97:25:fc:66:d6:69:
+         a3:36:08:85:23:ff:8e:3c:2b:e0:6d:e7:61:f1:00:8f:61:3d:
+         b0:87:ad:72:21:f6:f0:cc:4f:c9:20:bf:83:11:0f:21:f4:b8:
+         c0:dd:9c:51:d7:bb:27:32:ec:ab:a4:62:14:28:32:da:f2:87:
+         80:68:9c:ea:ac:eb:f5:7f:f5:de:f4:c0:39:91:c8:76:a4:ee:
+         d0:a8:50:db:c1:4b:f9:c4:3d:d9:e8:8e:b6:3f:c0:96:79:12:
+         d8:fa:4d:0a:b3:36:76:aa:4e:b2:82:2f:a2:d4:0d:db:fd:64:
+         77:6f:6e:e9:94:7f:0f:c8:3a:3c:96:3d:cd:4d:6c:ba:66:95:
+         f7:b4:9d:a4:94:9f:97:b3:9a:0d:dc:18:8c:11:0b:56:65:8e:
+         46:4c:e6:5e
+-----BEGIN CERTIFICATE-----
+MIID2jCCAsKgAwIBAgIBAjANBgkqhkiG9w0BAQsFADAdMRswGQYDVQQDDBJ3ZWIt
+cGxhdGZvcm0tdGVzdHMwHhcNMTQxMjIyMTIwOTE2WhcNMjQxMjIxMTIwOTE2WjAc
+MRowGAYDVQQDExF3ZWItcGxhdGZvcm0udGVzdDCCASIwDQYJKoZIhvcNAQEBBQAD
+ggEPADCCAQoCggEBALOE1osBWRiF0dwy3zj3kIUbPqVegT4v/Dpff3fvI7s6iCcP
+viVGzWN9y5XYpVAQ0qLSt5fRDWz7+QXob6hLvZVnnnuUWKltk/3gEsXNtIpkUjFf
+DuOJhHHamN1L7AIlpX01/mPas6zspUYPDWQjXG3z7MwoYyPAS5rsj8HusaI+ck1w
+tQnB67QQVTyL6huUfkt05vSfT6ZFMLXwuLTRWVBlCoZT6kyfnvRYbDH1FzpvV4vL
+X/AoC0WSjTAgSf9S5izLGJrX5u4+TzQ1FRPFAtrFX777W86Nv7U1djx85pw7JodN
+jYDmFsYn8lBJtnJ0Q0lJRDi7eEMj7hY+2WLmpdcCAwEAAaOCASQwggEgMAkGA1Ud
+EwQCMAAwHQYDVR0OBBYEFC2Yo5k5HP7pmm0XlNI6lu7IngQiMB8GA1UdIwQYMBaA
+FGqrU2SSNocjNLMdb4VL9d9aXHSPMAsGA1UdDwQEAwIF4DATBgNVHSUEDDAKBggr
+BgEFBQcDATCBsAYDVR0RBIGoMIGlghF3ZWItcGxhdGZvcm0udGVzdIIVd3d3Lndl
+Yi1wbGF0Zm9ybS50ZXN0gil4bi0tbjhqNmRzNTNsd3drcnFodjI4YS53ZWItcGxh
+dGZvcm0udGVzdIIeeG4tLWx2ZS02bGFkLndlYi1wbGF0Zm9ybS50ZXN0ghZ3d3cy
+LndlYi1wbGF0Zm9ybS50ZXN0ghZ3d3cxLndlYi1wbGF0Zm9ybS50ZXN0MA0GCSqG
+SIb3DQEBCwUAA4IBAQAz2/fw9pIWTy1CvLiq5qte+bmwSK61jcwCe+lvTnX3F6Be
+e4cGSUiDxbvKlQc3Dl3jl96eDKSCMBGBSV1QKXKSpcoXsXzxMhEXV+ZZwazjOybS
+lJdQarlUiISbb7EG9YAEIhAUsfWXJfxm1mmjNgiFI/+OPCvgbedh8QCPYT2wh61y
+IfbwzE/JIL+DEQ8h9LjA3ZxR17snMuyrpGIUKDLa8oeAaJzqrOv1f/Xe9MA5kch2
+pO7QqFDbwUv5xD3Z6I62P8CWeRLY+k0KszZ2qk6ygi+i1A3b/WR3b27plH8PyDo8
+lj3NTWy6ZpX3tJ2klJ+Xs5oN3BiMEQtWZY5GTOZe
+-----END CERTIFICATE-----
\ No newline at end of file
new file mode 100644
--- /dev/null
+++ b/testing/marionette/harness/marionette/runner/test.key
@@ -0,0 +1,28 @@
+-----BEGIN PRIVATE KEY-----
+MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQCzhNaLAVkYhdHc
+Mt8495CFGz6lXoE+L/w6X3937yO7OognD74lRs1jfcuV2KVQENKi0reX0Q1s+/kF
+6G+oS72VZ557lFipbZP94BLFzbSKZFIxXw7jiYRx2pjdS+wCJaV9Nf5j2rOs7KVG
+Dw1kI1xt8+zMKGMjwEua7I/B7rGiPnJNcLUJweu0EFU8i+oblH5LdOb0n0+mRTC1
+8Li00VlQZQqGU+pMn570WGwx9Rc6b1eLy1/wKAtFko0wIEn/UuYsyxia1+buPk80
+NRUTxQLaxV+++1vOjb+1NXY8fOacOyaHTY2A5hbGJ/JQSbZydENJSUQ4u3hDI+4W
+Ptli5qXXAgMBAAECggEBAIcwDQSnIjo2ZECHytQykpG6X6XXEksLhc1Lp0lhPC49
+uNR5pX6a4AcBb3PLr0opMQZO2tUoKA0ff3t0e8loKD+/xXhY0Z/dlioEOP7elwv0
+2nS1mhe9spCuxpk4GGXRhdtR8t2tj8s0do3YvgPgITXoEDX6YBZHNGhZpzSrFPgQ
+/c3eGCVmzWYuLFfdj5OPQ9bwTaY4JSvDLZT0/WTgiica7VySwfz3HP1fFqNykTiK
+ACQREvtxfk5Ym2nT6oni7CM2zOEJL9SXicXI5HO4bERH0ZYh//F3g6mwGiFXUJPd
+NKgaTM1oT9kRGkUaEYsRWrddwR8d5mXLvBuTJbgIsSECgYEA1+2uJSYRW1OqbhYP
+ms59YQHSs3VjpJpnCV2zNa2Wixs57KS2cOH7B6KrQCogJFLtgCDVLtyoErfVkD7E
+FivTgYr1pVCRppJddQzXik31uOINOBVffr7/09g3GcRN+ubHPZPq3K+dD6gHa3Aj
+0nH1EjEEV0QpSTQFn87OF2mc9wcCgYEA1NVqMbbzd+9Xft5FXuSbX6E+S02dOGat
+SgpnkTM80rjqa6eHdQzqk3JqyteHPgdi1vdYRlSPOj/X+6tySY0Ej9sRnYOfddA2
+kpiDiVkmiqVolyJPY69Utj+E3TzJ1vhCQuYknJmB7zP9tDcTxMeq0l/NaWvGshEK
+yC4UTQog1rECgYASOFILfGzWgfbNlzr12xqlRtwanHst9oFfPvLSQrWDQ2bd2wAy
+Aj+GY2mD3oobxouX1i1m6OOdwLlalJFDNauBMNKNgoDnx03vhIfjebSURy7KXrNS
+JJe9rm7n07KoyzRgs8yLlp3wJkOKA0pihY8iW9R78JpzPNqEo5SsURMXnQKBgBlV
+gfuC9H4tPjP6zzUZbyk1701VYsaI6k2q6WMOP0ox+q1v1p7nN7DvaKjWeOG4TVqb
+PKW6gQYE/XeWk9cPcyCQigs+1KdYbnaKsvWRaBYO1GFREzQhdarv6qfPCZOOH40J
+Cgid+Sp4/NULzU2aGspJ3xCSZKdjge4MFhyJfRkxAoGBAJlwqY4nue0MBLGNpqcs
+WwDtSasHvegKAcxGBKL5oWPbLBk7hk+hdqc8f6YqCkCNqv/ooBspL15ESItL+6yT
+zt0YkK4oH9tmLDb+rvqZ7ZdXbWSwKITMoCyyHUtT6OKt/RtA0Vdy9LPnP27oSO/C
+dk8Qf7KgKZLWo0ZNkvw38tEC
+-----END PRIVATE KEY-----
\ No newline at end of file
new file mode 100644
--- /dev/null
+++ b/testing/marionette/harness/marionette/tests/harness_unit/test_httpd.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/.
+
+import json
+import os
+import types
+import urllib2
+
+import pytest
+
+from marionette.runner import httpd
+from wptserve.handlers import json_handler
+
+here = os.path.abspath(os.path.dirname(__file__))
+parent = os.path.dirname(here)
+default_doc_root = os.path.join(os.path.dirname(parent), "www")
+
+
+@pytest.yield_fixture
+def server():
+    server = httpd.FixtureServer(default_doc_root)
+    yield server
+    server.stop()
+
+
+def test_ctor():
+    with pytest.raises(ValueError):
+        httpd.FixtureServer("foo")
+    httpd.FixtureServer(default_doc_root)
+
+
+def test_start_stop(server):
+    server.start()
+    server.stop()
+
+
+def test_get_url(server):
+    server.start()
+    url = server.get_url("/")
+    assert isinstance(url, types.StringTypes)
+    assert "http://" in url
+
+    server.stop()
+    with pytest.raises(httpd.NotAliveError):
+        server.get_url("/")
+
+
+def test_doc_root(server):
+    server.start()
+    assert isinstance(server.doc_root, types.StringTypes)
+    server.stop()
+    assert isinstance(server.doc_root, types.StringTypes)
+
+
+def test_router(server):
+    assert server.router is not None
+
+
+def test_routes(server):
+    assert server.routes is not None
+
+
+def test_is_alive(server):
+    assert server.is_alive == False
+    server.start()
+    assert server.is_alive == True
+
+
+def test_handler(server):
+    counter = 0
+
+    @json_handler
+    def handler(request, response):
+        return {"count": counter}
+
+    route = ("GET", "/httpd/test_handler", handler)
+    server.router.register(*route)
+    server.start()
+
+    url = server.get_url("/httpd/test_handler")
+    body = urllib2.urlopen(url).read()
+    res = json.loads(body)
+    assert res["count"] == counter
+
+
+if __name__ == "__main__":
+    import sys
+    sys.exit(pytest.main(["--verbose", __file__]))
--- a/testing/marionette/harness/marionette/tests/harness_unit/test_marionette_runner.py
+++ b/testing/marionette/harness/marionette/tests/harness_unit/test_marionette_runner.py
@@ -20,17 +20,17 @@ def runner(mach_parsed_kwargs):
 @pytest.fixture
 def mock_runner(runner, mock_marionette, monkeypatch):
     """
     MarionetteTestRunner instance with mocked-out
     self.marionette and other properties,
     to enable testing runner.run_tests().
     """
     runner.driverclass = mock_marionette
-    for attr in ['_set_baseurl', 'run_test_set', '_capabilities']:
+    for attr in ['run_test_set', '_capabilities']:
         setattr(runner, attr, Mock())
     runner._appName = 'fake_app'
     monkeypatch.setattr('marionette.runner.base.mozversion', Mock())
     return runner
 
 
 @pytest.fixture
 def build_kwargs_using(mach_parsed_kwargs):
@@ -347,17 +347,17 @@ def test_cleanup_with_manifest(mock_runn
     monkeypatch.setattr('marionette.runner.base.TestManifest', manifest_with_tests.manifest_class)
     if manifest_with_tests.n_enabled > 0:
         context = patch('marionette.runner.base.os.path.exists', return_value=True)
     else:
         context = pytest.raises(Exception)
     with context:
         mock_runner.run_tests([manifest_with_tests.filepath])
     assert mock_runner.marionette is None
-    assert mock_runner.httpd is None
+    assert mock_runner.fixture_servers == {}
 
 
 def test_reset_test_stats(mock_runner):
     def reset_successful(runner):
         stats = ['passed', 'failed', 'unexpected_successes', 'todo', 'skipped', 'failures']
         return all([((s in vars(runner)) and (not vars(runner)[s])) for s in stats])
     assert reset_successful(mock_runner)
     mock_runner.passed = 1
new file mode 100644
--- /dev/null
+++ b/testing/marionette/harness/marionette/tests/harness_unit/test_serve.py
@@ -0,0 +1,67 @@
+# 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 types
+
+import pytest
+
+from marionette.runner import serve
+from marionette.runner.serve import iter_proc, iter_url
+
+
+def teardown_function(func):
+    for server in [server for server in iter_proc(serve.servers) if server.is_alive]:
+        server.stop()
+        server.kill()
+
+
+def test_registered_servers():
+    # [(name, factory), ...]
+    assert serve.registered_servers[0][0] == "http"
+    assert serve.registered_servers[1][0] == "https"
+
+
+def test_globals():
+    assert serve.default_doc_root is not None
+    assert serve.registered_servers is not None
+    assert serve.servers is not None
+
+
+def test_start():
+    serve.start()
+    assert len(serve.servers) == 2
+    assert "http" in serve.servers
+    assert "https" in serve.servers
+    for url in iter_url(serve.servers):
+        assert isinstance(url, types.StringTypes)
+
+
+def test_start_with_custom_root(tmpdir_factory):
+    tdir = tmpdir_factory.mktemp("foo")
+    serve.start(str(tdir))
+    for server in iter_proc(serve.servers):
+        assert server.doc_root == tdir
+
+
+def test_iter_proc():
+    serve.start()
+    for server in iter_proc(serve.servers):
+        server.stop()
+
+
+def test_iter_url():
+    serve.start()
+    for url in iter_url(serve.servers):
+        assert isinstance(url, types.StringTypes)
+
+
+def test_where_is():
+    serve.start()
+    assert serve.where_is("/") == serve.servers["http"][1].get_url("/")
+    assert serve.where_is("/", on="https") == serve.servers["https"][1].get_url("/")
+
+
+if __name__ == "__main__":
+    import sys
+    sys.exit(pytest.main(["-s", "--verbose", __file__]))
deleted file mode 100644
--- a/testing/marionette/harness/marionette/tests/unit/test_httpd.py
+++ /dev/null
@@ -1,32 +0,0 @@
-# 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 marionette import MarionetteTestCase
-from marionette_driver import By
-
-
-class TestHttpdServer(MarionetteTestCase):
-
-    def test_handler(self):
-        status = {"count": 0}
-
-        def handler(request, response):
-            status["count"] += 1
-
-            response.headers.set("Content-Type", "text/html")
-            response.content = "<html><body><p id=\"count\">{}</p></body></html>".format(
-                status["count"])
-
-            return ()
-
-        route = ("GET", "/httpd/test_handler", handler)
-        self.httpd.router.register(*route)
-
-        url = self.marionette.absolute_url("httpd/test_handler")
-
-        for counter in range(0, 5):
-            self.marionette.navigate(url)
-            self.assertEqual(status["count"], counter + 1)
-            elem = self.marionette.find_element(By.ID, "count")
-            self.assertEqual(elem.text, str(counter + 1))
--- a/testing/marionette/harness/marionette/tests/unit/unit-tests.ini
+++ b/testing/marionette/harness/marionette/tests/unit/unit-tests.ini
@@ -118,10 +118,9 @@ skip-if = appname == 'fennec' || os == "
 
 [test_chrome.py]
 skip-if = appname == 'fennec'
 
 [test_addons.py]
 
 [test_select.py]
 [test_crash.py]
-[test_httpd.py]
 [test_localization.py]
--- a/testing/marionette/harness/requirements.txt
+++ b/testing/marionette/harness/requirements.txt
@@ -1,14 +1,14 @@
-marionette-driver >= 2.1.0
 browsermob-proxy >= 0.6.0
 manifestparser >= 1.1
-wptserve >= 1.3.0
+marionette-driver >= 2.1.0
+mozcrash >= 0.5
+mozdevice >= 0.44
 mozinfo >= 0.8
-mozprocess >= 0.9
-mozrunner >= 6.13
-mozdevice >= 0.44
 mozlog >= 3.0
 moznetwork >= 0.21
-mozcrash >= 0.5
+mozprocess >= 0.9
 mozprofile >= 0.7
+mozrunner >= 6.13
 moztest >= 0.7
 mozversion >= 1.1
+wptserve >= 1.3.0