Bug 1449837 [wpt PR 10231] - Make the normalized wptserve config a class, a=testonly
authorGeoffrey Sneddon <me@gsnedders.com>
Thu, 19 Apr 2018 11:14:58 +0000
changeset 414745 5ee6c340f4799cd104cd4c5de6d9153636e76806
parent 414744 68902312e8873165baa2d3c1b417610927c51314
child 414746 3ff02137cf9f34ce14cdf832ca25824f25862f9d
push id102420
push userjames@hoppipolla.co.uk
push dateFri, 20 Apr 2018 21:04:12 +0000
treeherdermozilla-inbound@0605371779f8 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewerstestonly
bugs1449837, 10231
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 1449837 [wpt PR 10231] - Make the normalized wptserve config a class, a=testonly Automatic update from web-platform-testsMove the config into its own class (#10231) -- wpt-commits: bfef1f20a419d24633e48d24c14e6a7503e1d48c wpt-pr: 10231 wpt-commits: bfef1f20a419d24633e48d24c14e6a7503e1d48c wpt-pr: 10231
testing/web-platform/tests/tools/ci/make_hosts_file.py
testing/web-platform/tests/tools/serve/serve.py
testing/web-platform/tests/tools/wpt/run.py
testing/web-platform/tests/tools/wptrunner/wptrunner/browsers/firefox.py
testing/web-platform/tests/tools/wptrunner/wptrunner/environment.py
testing/web-platform/tests/tools/wptrunner/wptrunner/tests/browsers/test_sauce.py
testing/web-platform/tests/tools/wptserve/tests/test_config.py
testing/web-platform/tests/tools/wptserve/wptserve/config.py
testing/web-platform/tests/tools/wptserve/wptserve/utils.py
--- a/testing/web-platform/tests/tools/ci/make_hosts_file.py
+++ b/testing/web-platform/tests/tools/ci/make_hosts_file.py
@@ -1,19 +1,17 @@
 import argparse
 import os
 
 from ..localpaths import repo_root
 
-from ..serve.serve import load_config, normalise_config, make_hosts_file
+from ..serve.serve import load_config, make_hosts_file
 
 def create_parser():
     parser = argparse.ArgumentParser()
     parser.add_argument("address", default="127.0.0.1", nargs="?", help="Address that hosts should point at")
     return parser
 
 def run(**kwargs):
     config = load_config(os.path.join(repo_root, "config.default.json"),
                          os.path.join(repo_root, "config.json"))
 
-    config = normalise_config(config, {})
-
     print(make_hosts_file(config, kwargs["address"]))
--- a/testing/web-platform/tests/tools/serve/serve.py
+++ b/testing/web-platform/tests/tools/serve/serve.py
@@ -18,18 +18,20 @@ from collections import defaultdict, Ord
 from multiprocessing import Process, Event
 
 from localpaths import repo_root
 
 import sslutils
 from manifest.sourcefile import read_script_metadata, js_meta_re
 from wptserve import server as wptserve, handlers
 from wptserve import stash
+from wptserve import config
 from wptserve.logger import set_logger
 from wptserve.handlers import filesystem_path, wrap_pipeline
+from wptserve.utils import get_port
 from mod_pywebsocket import standalone as pywebsocket
 
 def replace_end(s, old, new):
     """
     Given a string `s` that ends with `old`, replace that occurrence of `old`
     with `new`.
     """
     assert s.endswith(old)
@@ -192,24 +194,16 @@ done();
         if key == b"script":
             attribute = value.decode('utf-8').replace("\\", "\\\\").replace('"', '\\"')
             return 'importScripts("%s")' % attribute
         return None
 
 
 rewrites = [("GET", "/resources/WebIDLParser.js", "/resources/webidl2/lib/webidl2.js")]
 
-subdomains = [u"www",
-              u"www1",
-              u"www2",
-              u"天気の良い日",
-              u"élève"]
-
-not_subdomains = [u"nonexistent-origin"]
-
 class RoutesBuilder(object):
     def __init__(self):
         self.forbidden_override = [("GET", "/tools/runner/*", handlers.file_handler),
                                    ("POST", "/tools/runner/update_manifest.py",
                                     handlers.python_script_handler)]
 
         self.forbidden = [("*", "/_certs/*", handlers.ErrorHandler(404)),
                           ("*", "/tools/*", handlers.ErrorHandler(404)),
@@ -277,115 +271,16 @@ def build_routes(aliases):
             continue
         if url.endswith("/"):
             builder.add_mount_point(url, directory)
         else:
             builder.add_file_mount_point(url, directory)
     return builder.get_routes()
 
 
-def setup_logger(level):
-    import logging
-    global logger
-    logger = logging.getLogger("web-platform-tests")
-    logger.setLevel(getattr(logging, level.upper()))
-    set_logger(logger)
-
-
-def open_socket(port):
-    sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
-    if port != 0:
-        sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
-    sock.bind(('127.0.0.1', port))
-    sock.listen(5)
-    return sock
-
-def bad_port(port):
-    """
-    Bad port as per https://fetch.spec.whatwg.org/#port-blocking
-    """
-    return port in [
-        1,     # tcpmux
-        7,     # echo
-        9,     # discard
-        11,    # systat
-        13,    # daytime
-        15,    # netstat
-        17,    # qotd
-        19,    # chargen
-        20,    # ftp-data
-        21,    # ftp
-        22,    # ssh
-        23,    # telnet
-        25,    # smtp
-        37,    # time
-        42,    # name
-        43,    # nicname
-        53,    # domain
-        77,    # priv-rjs
-        79,    # finger
-        87,    # ttylink
-        95,    # supdup
-        101,   # hostriame
-        102,   # iso-tsap
-        103,   # gppitnp
-        104,   # acr-nema
-        109,   # pop2
-        110,   # pop3
-        111,   # sunrpc
-        113,   # auth
-        115,   # sftp
-        117,   # uucp-path
-        119,   # nntp
-        123,   # ntp
-        135,   # loc-srv / epmap
-        139,   # netbios
-        143,   # imap2
-        179,   # bgp
-        389,   # ldap
-        465,   # smtp+ssl
-        512,   # print / exec
-        513,   # login
-        514,   # shell
-        515,   # printer
-        526,   # tempo
-        530,   # courier
-        531,   # chat
-        532,   # netnews
-        540,   # uucp
-        556,   # remotefs
-        563,   # nntp+ssl
-        587,   # smtp
-        601,   # syslog-conn
-        636,   # ldap+ssl
-        993,   # imap+ssl
-        995,   # pop3+ssl
-        2049,  # nfs
-        3659,  # apple-sasl
-        4045,  # lockd
-        6000,  # x11
-        6665,  # irc (alternate)
-        6666,  # irc (alternate)
-        6667,  # irc (default)
-        6668,  # irc (alternate)
-        6669,  # irc (alternate)
-    ]
-
-def get_port():
-    port = 0
-    while True:
-        free_socket = open_socket(0)
-        port = free_socket.getsockname()[1]
-        free_socket.close()
-        if not bad_port(port):
-            break
-    logger.debug("Going to use port %s" % port)
-    return port
-
-
 class ServerProc(object):
     def __init__(self):
         self.proc = None
         self.daemon = None
         self.stop = Event()
 
     def start(self, init_func, host, port, paths, routes, bind_address, config,
               ssl_config, **kwargs):
@@ -427,19 +322,21 @@ class ServerProc(object):
         self.stop.set()
         self.proc.terminate()
         self.proc.join()
 
     def is_alive(self):
         return self.proc.is_alive()
 
 
-def check_subdomains(host, paths, bind_address, ssl_config, aliases):
-    port = get_port()
-    subdomains = get_subdomains(host)
+def check_subdomains(domains, paths, bind_address, ssl_config, aliases):
+    domains = domains.copy()
+    host = domains.pop("")
+    port = get_port(host)
+    logger.debug("Going to use port %d to check subdomains" % port)
 
     wrapper = ServerProc()
     wrapper.start(start_http_server, host, port, paths, build_routes(aliases), bind_address,
                   None, ssl_config)
 
     connected = False
     for i in range(10):
         try:
@@ -449,40 +346,27 @@ def check_subdomains(host, paths, bind_a
         except urllib2.URLError:
             time.sleep(1)
 
     if not connected:
         logger.critical("Failed to connect to test server on http://%s:%s. "
                         "You may need to edit /etc/hosts or similar, see README.md." % (host, port))
         sys.exit(1)
 
-    for subdomain, (punycode, host) in subdomains.iteritems():
-        domain = "%s.%s" % (punycode, host)
+    for domain in domains.itervalues():
         try:
             urllib2.urlopen("http://%s:%d/" % (domain, port))
         except Exception as e:
             logger.critical("Failed probing domain %s. "
                             "You may need to edit /etc/hosts or similar, see README.md." % domain)
             sys.exit(1)
 
     wrapper.wait()
 
 
-def get_subdomains(host):
-    #This assumes that the tld is ascii-only or already in punycode
-    return {subdomain: (subdomain.encode("idna"), host)
-            for subdomain in subdomains}
-
-
-def get_not_subdomains(host):
-    #This assumes that the tld is ascii-only or already in punycode
-    return {subdomain: (subdomain.encode("idna"), host)
-            for subdomain in not_subdomains}
-
-
 def make_hosts_file(config, host):
     rv = []
 
     for domain in config["domains"].values():
         rv.append("%s\t%s\n" % (host, domain))
 
     for not_domain in config.get("not_domains", {}).values():
         rv.append("0.0.0.0\t%s\n" % not_domain)
@@ -625,183 +509,92 @@ def start_wss_server(host, port, paths, 
                            str(port),
                            repo_root,
                            paths["ws_doc_root"],
                            "debug",
                            bind_address,
                            ssl_config)
 
 
-def get_ports(config, ssl_environment):
-    rv = defaultdict(list)
-    for scheme, ports in config["ports"].iteritems():
-        for i, port in enumerate(ports):
-            if scheme in ["wss", "https"] and not ssl_environment.ssl_enabled:
-                port = None
-            if port == "auto":
-                port = get_port()
-            else:
-                port = port
-            rv[scheme].append(port)
-    return rv
-
-
-
-def normalise_config(config, ports):
-    if "host" in config:
-        logger.warning("host in config is deprecated; use browser_host instead")
-        host = config["host"]
-    else:
-        host = config["browser_host"]
-
-    domains = get_subdomains(host)
-    not_domains = get_not_subdomains(host)
-
-    ports_ = {}
-    for scheme, ports_used in ports.iteritems():
-        ports_[scheme] = ports_used
-
-    for key, value in domains.iteritems():
-        domains[key] = ".".join(value)
-
-    for key, value in not_domains.iteritems():
-        not_domains[key] = ".".join(value)
-
-    domains[""] = host
-
-    if "bind_hostname" in config:
-        logger.warning("bind_hostname in config is deprecated; use bind_address instead")
-        bind_address = config["bind_hostname"]
-    else:
-        bind_address = config["bind_address"]
-
-    # make a (shallow) copy of the config and update that, so that the
-    # normalized config can be used in place of the original one.
-    config_ = config.copy()
-    config_["domains"] = domains
-    config_["not_domains"] = not_domains
-    config_["ports"] = ports_
-    config_["bind_address"] = bind_address
-    if config.get("server_host", None) is None:
-        config_["server_host"] = host
-    return config_
-
-
-def get_paths(config):
-    return {"doc_root": config["doc_root"],
-            "ws_doc_root": config["ws_doc_root"]}
-
-
-def get_ssl_config(config, ssl_environment):
-    external_domains = config["domains"].values()
-    key_path, cert_path = ssl_environment.host_cert_path(external_domains)
-    return {"key_path": key_path,
-            "cert_path": cert_path,
-            "encrypt_after_connect": config["ssl"]["encrypt_after_connect"]}
-
-
 def start(config, ssl_environment, routes, **kwargs):
     host = config["server_host"]
-    ports = get_ports(config, ssl_environment)
-    paths = get_paths(config)
+    ports = config.ports
+    paths = config.paths
     bind_address = config["bind_address"]
-    ssl_config = get_ssl_config(config, ssl_environment)
+    ssl_config = config.ssl_config
+
+    logger.debug("Using ports: %r" % ports)
 
     servers = start_servers(host, ports, paths, routes, bind_address, config,
                             ssl_config, **kwargs)
 
     return servers
 
 
 def iter_procs(servers):
     for servers in servers.values():
         for port, server in servers:
             yield server.proc
 
 
-def value_set(config, key):
-    return key in config and config[key] is not None
-
-
-def get_value_or_default(config, key, default=None):
-    return config[key] if value_set(config, key) else default
-
-
-def set_computed_defaults(config):
-    if not value_set(config, "doc_root"):
-        config["doc_root"] = repo_root
-
-    if not value_set(config, "ws_doc_root"):
-        root = get_value_or_default(config, "doc_root", default=repo_root)
-        config["ws_doc_root"] = os.path.join(root, "websockets", "handlers")
-
-    if not value_set(config, "aliases"):
-        config["aliases"] = []
-
-
-def merge_json(base_obj, override_obj):
-    rv = {}
-    for key, value in base_obj.iteritems():
-        if key not in override_obj:
-            rv[key] = value
-        else:
-            if isinstance(value, dict):
-                rv[key] = merge_json(value, override_obj[key])
-            else:
-                rv[key] = override_obj[key]
-    return rv
-
-
-def get_ssl_environment(config):
-    implementation_type = config["ssl"]["type"]
-    cls = sslutils.environments[implementation_type]
-    try:
-        kwargs = config["ssl"][implementation_type].copy()
-    except KeyError:
-        raise ValueError("%s is not a vaid ssl type." % implementation_type)
-    return cls(logger, **kwargs)
-
-
 def load_config(default_path, override_path=None, **kwargs):
     if os.path.exists(default_path):
         with open(default_path) as f:
             base_obj = json.load(f)
     else:
         raise ValueError("Config path %s does not exist" % default_path)
 
+    rv = Config(**base_obj)
+
     if os.path.exists(override_path):
         with open(override_path) as f:
             override_obj = json.load(f)
-    else:
-        override_obj = {}
-    rv = merge_json(base_obj, override_obj)
+        rv.update(override_obj)
 
     if kwargs.get("config_path"):
         other_path = os.path.abspath(os.path.expanduser(kwargs.get("config_path")))
         if os.path.exists(other_path):
-            base_obj = rv
             with open(other_path) as f:
                 override_obj = json.load(f)
-            rv = merge_json(base_obj, override_obj)
+            rv.update(override_obj)
         else:
             raise ValueError("Config path %s does not exist" % other_path)
 
     overriding_path_args = [("doc_root", "Document root"),
                             ("ws_doc_root", "WebSockets document root")]
     for key, title in overriding_path_args:
         value = kwargs.get(key)
         if value is None:
             continue
         value = os.path.abspath(os.path.expanduser(value))
         if not os.path.exists(value):
             raise ValueError("%s path %s does not exist" % (title, value))
-        rv[key] = value
+        setattr(rv, key, value)
+
+    return rv
+
+_subdomains = {u"www",
+               u"www1",
+               u"www2",
+               u"天気の良い日",
+               u"élève"}
+
+_not_subdomains = {u"nonexistent-origin"}
 
-    set_computed_defaults(rv)
-    return rv
+class Config(config.Config):
+    """serve config
+
+    this subclasses wptserve.config.Config to add serve config options"""
+    def __init__(self, *args, **kwargs):
+        super(Config, self).__init__(
+            subdomains=_subdomains,
+            not_subdomains=_not_subdomains,
+            *args,
+            **kwargs
+        )
 
 
 def get_parser():
     parser = argparse.ArgumentParser()
     parser.add_argument("--latency", type=int,
                         help="Artificial latency to add before sending http responses, in ms")
     parser.add_argument("--config", action="store", dest="config_path",
                         help="Path to external config file")
@@ -812,40 +605,38 @@ def get_parser():
     return parser
 
 
 def run(**kwargs):
     config = load_config(os.path.join(repo_root, "config.default.json"),
                          os.path.join(repo_root, "config.json"),
                          **kwargs)
 
-    setup_logger(config["log_level"])
+    global logger
+    logger = config.logger
+    set_logger(logger)
 
-    with get_ssl_environment(config) as ssl_env:
-        ports = get_ports(config, ssl_env)
-        config = normalise_config(config, ports)
-        browser_host = config["browser_host"]
-        server_host = config["server_host"]
-        bind_address = config["bind_address"]
+    bind_address = config["bind_address"]
 
-        if config["check_subdomains"]:
-            paths = get_paths(config)
-            ssl_config = get_ssl_config(config, ssl_env)
-            check_subdomains(browser_host, paths, bind_address, ssl_config, config["aliases"])
+    if config["check_subdomains"]:
+        paths = config.paths
+        ssl_config = config.ssl_config
+        check_subdomains(config.domains, paths, bind_address, ssl_config, config["aliases"])
 
-        stash_address = None
-        if bind_address:
-            stash_address = (server_host, get_port())
-
-        with stash.StashServer(stash_address, authkey=str(uuid.uuid4())):
-            servers = start(config, ssl_env, build_routes(config["aliases"]), **kwargs)
+    stash_address = None
+    if bind_address:
+        stash_address = (config.server_host, get_port(config.server_host))
+        logger.debug("Going to use port %d for stash" % stash_address[1])
 
-            try:
-                while any(item.is_alive() for item in iter_procs(servers)):
-                    for item in iter_procs(servers):
-                        item.join(1)
-            except KeyboardInterrupt:
-                logger.info("Shutting down")
+    with stash.StashServer(stash_address, authkey=str(uuid.uuid4())):
+        servers = start(config, config.ssl_env, build_routes(config["aliases"]), **kwargs)
+
+        try:
+            while any(item.is_alive() for item in iter_procs(servers)):
+                for item in iter_procs(servers):
+                    item.join(1)
+        except KeyboardInterrupt:
+            logger.info("Shutting down")
 
 
 def main():
     kwargs = vars(get_parser().parse_args())
     return run(**kwargs)
--- a/testing/web-platform/tests/tools/wpt/run.py
+++ b/testing/web-platform/tests/tools/wpt/run.py
@@ -93,17 +93,16 @@ Otherwise run with --ssl-type=none""")
                 raise WptrunError("""OpenSSL not found. If you don't need HTTPS support run with --ssl-type=none,
 otherwise install OpenSSL and ensure that it's on your $PATH.""")
 
 
 def check_environ(product):
     if product not in ("firefox", "servo"):
         config = serve.load_config(os.path.join(wpt_root, "config.default.json"),
                                    os.path.join(wpt_root, "config.json"))
-        config = serve.normalise_config(config, {})
         expected_hosts = (set(config["domains"].itervalues()) ^
                           set(config["not_domains"].itervalues()))
         missing_hosts = set(expected_hosts)
         if platform.uname()[0] != "Windows":
             hosts_path = "/etc/hosts"
         else:
             hosts_path = "C:\Windows\System32\drivers\etc\hosts"
         with open(hosts_path, "r") as f:
--- a/testing/web-platform/tests/tools/wptrunner/wptrunner/browsers/firefox.py
+++ b/testing/web-platform/tests/tools/wptrunner/wptrunner/browsers/firefox.py
@@ -97,17 +97,17 @@ def executor_kwargs(test_type, server_co
         executor_kwargs["reftest_screenshot"] = kwargs["reftest_screenshot"]
     if test_type == "wdspec":
         options = {}
         if kwargs["binary"]:
             options["binary"] = kwargs["binary"]
         if kwargs["binary_args"]:
             options["args"] = kwargs["binary_args"]
         options["prefs"] = {
-            "network.dns.localDomains": ",".join(server_config['domains'].values())
+            "network.dns.localDomains": ",".join(server_config.domains.itervalues())
         }
         capabilities["moz:firefoxOptions"] = options
     if kwargs["certutil_binary"] is None:
         capabilities["acceptInsecureCerts"] = True
     if capabilities:
         executor_kwargs["capabilities"] = capabilities
     return executor_kwargs
 
@@ -193,17 +193,17 @@ class FirefoxBrowser(Browser):
         if self.chaos_mode_flags is not None:
             env["MOZ_CHAOSMODE"] = str(self.chaos_mode_flags)
 
         preferences = self.load_prefs()
 
         self.profile = FirefoxProfile(preferences=preferences)
         self.profile.set_preferences({"marionette.port": self.marionette_port,
                                       "dom.disable_open_during_load": False,
-                                      "network.dns.localDomains": ",".join(self.config['domains'].values()),
+                                      "network.dns.localDomains": ",".join(self.config.domains.itervalues()),
                                       "network.proxy.type": 0,
                                       "places.history.enabled": False,
                                       "dom.send_after_paint_to_content": True,
                                       "network.preload": True})
         if self.e10s:
             self.profile.set_preferences({"browser.tabs.remote.autostart": True})
 
         if self.test_type == "reftest":
--- a/testing/web-platform/tests/tools/wptrunner/wptrunner/environment.py
+++ b/testing/web-platform/tests/tools/wptrunner/wptrunner/environment.py
@@ -89,18 +89,16 @@ class TestEnvironment(object):
 
     def __enter__(self):
         self.stash.__enter__()
         self.ssl_env.__enter__()
         self.cache_manager.__enter__()
 
         self.config = self.load_config()
         self.setup_server_logging()
-        ports = serve.get_ports(self.config, self.ssl_env)
-        self.config = serve.normalise_config(self.config, ports)
 
         assert self.env_extras_cms is None, (
             "A TestEnvironment object cannot be nested")
 
         self.env_extras_cms = []
 
         for env in self.env_extras:
             cm = env(self.options, self.config)
@@ -132,53 +130,39 @@ class TestEnvironment(object):
     def ignore_interrupts(self):
         signal.signal(signal.SIGINT, signal.SIG_IGN)
 
     def process_interrupts(self):
         signal.signal(signal.SIGINT, signal.SIG_DFL)
 
     def load_config(self):
         default_config_path = os.path.join(serve_path(self.test_paths), "config.default.json")
-        local_config = {
-            "ports": {
-                "http": [8000, 8001],
-                "https": [8443],
-                "ws": [8888]
-            },
-            "check_subdomains": False,
-            "ssl": {}
-        }
-
-        if "browser_host" in self.options:
-            local_config["browser_host"] = self.options["browser_host"]
-
-        if "bind_address" in self.options:
-            local_config["bind_address"] = self.options["bind_address"]
 
         with open(default_config_path) as f:
             default_config = json.load(f)
 
-        local_config["server_host"] = self.options.get("server_host", None)
-        local_config["ssl"]["encrypt_after_connect"] = self.options.get("encrypt_after_connect", False)
+        config = serve.Config(override_ssl_env=self.ssl_env, **default_config)
 
-        config = serve.merge_json(default_config, local_config)
-        config["doc_root"] = serve_path(self.test_paths)
-
-        if not self.ssl_env.ssl_enabled:
-            config["ports"]["https"] = [None]
+        config.ports = {
+            "http": [8000, 8001],
+            "https": [8443],
+            "ws": [8888]
+        }
+        config.check_subdomains = False
+        config.ssl = {}
 
-        host = config["browser_host"]
-        hosts = [host]
-        hosts.extend("%s.%s" % (item[0], host) for item in serve.get_subdomains(host).values())
-        key_file, certificate = self.ssl_env.host_cert_path(hosts)
+        if "browser_host" in self.options:
+            config.browser_host = self.options["browser_host"]
 
-        config["key_file"] = key_file
-        config["certificate"] = certificate
+        if "bind_address" in self.options:
+            config.bind_address = self.options["bind_address"]
 
-        serve.set_computed_defaults(config)
+        config.server_host = self.options.get("server_host", None)
+        config.ssl["encrypt_after_connect"] = self.options.get("encrypt_after_connect", False)
+        config.doc_root = serve_path(self.test_paths)
 
         return config
 
     def setup_server_logging(self):
         server_logger = get_default_logger(component="wptserve")
         assert server_logger is not None
         log_filter = handlers.LogLevelFilter(lambda x:x, "info")
         # Downgrade errors to warnings for the server
--- a/testing/web-platform/tests/tools/wptrunner/wptrunner/tests/browsers/test_sauce.py
+++ b/testing/web-platform/tests/tools/wptrunner/wptrunner/tests/browsers/test_sauce.py
@@ -3,16 +3,18 @@ from os.path import join, dirname
 
 import mock
 import pytest
 
 sys.path.insert(0, join(dirname(__file__), "..", "..", ".."))
 
 sauce = pytest.importorskip("wptrunner.browsers.sauce")
 
+from wptserve.config import Config
+
 
 def test_sauceconnect_success():
     with mock.patch.object(sauce.SauceConnect, "upload_prerun_exec"),\
             mock.patch.object(sauce.subprocess, "Popen") as Popen,\
             mock.patch.object(sauce.os.path, "exists") as exists:
         # Act as if it's still running
         Popen.return_value.poll.return_value = None
         Popen.return_value.returncode = None
@@ -20,19 +22,17 @@ def test_sauceconnect_success():
         exists.return_value = True
 
         sauce_connect = sauce.SauceConnect(
             sauce_user="aaa",
             sauce_key="bbb",
             sauce_tunnel_id="ccc",
             sauce_connect_binary="ddd")
 
-        env_config = {
-            "domains": {"": "example.net"}
-        }
+        env_config = Config(browser_host="example.net")
         sauce_connect(None, env_config)
         with sauce_connect:
             pass
 
 
 @pytest.mark.parametrize("readyfile,returncode", [
     (True, 0),
     (True, 1),
@@ -51,19 +51,17 @@ def test_sauceconnect_failure_exit(ready
         exists.return_value = readyfile
 
         sauce_connect = sauce.SauceConnect(
             sauce_user="aaa",
             sauce_key="bbb",
             sauce_tunnel_id="ccc",
             sauce_connect_binary="ddd")
 
-        env_config = {
-            "domains": {"": "example.net"}
-        }
+        env_config = Config(browser_host="example.net")
         sauce_connect(None, env_config)
         with pytest.raises(sauce.SauceException):
             with sauce_connect:
                 pass
 
         # Given we appear to exit immediately with these mocks, sleep shouldn't be called
         sleep.assert_not_called()
 
@@ -78,19 +76,17 @@ def test_sauceconnect_failure_never_read
         exists.return_value = False
 
         sauce_connect = sauce.SauceConnect(
             sauce_user="aaa",
             sauce_key="bbb",
             sauce_tunnel_id="ccc",
             sauce_connect_binary="ddd")
 
-        env_config = {
-            "domains": {"": "example.net"}
-        }
+        env_config = Config(browser_host="example.net")
         sauce_connect(None, env_config)
         with pytest.raises(sauce.SauceException):
             with sauce_connect:
                 pass
 
         # We should sleep while waiting for it to create the readyfile
         sleep.assert_called()
 
@@ -108,23 +104,25 @@ def test_sauceconnect_tunnel_domains():
         exists.return_value = True
 
         sauce_connect = sauce.SauceConnect(
             sauce_user="aaa",
             sauce_key="bbb",
             sauce_tunnel_id="ccc",
             sauce_connect_binary="ddd")
 
-        env_config = {
-            "domains": {"foo": "foo.bar.example.com", "": "example.net"}
-        }
+        env_config = Config(browser_host="example.net",
+                            subdomains={"a", "b"},
+                            not_subdomains={"x", "y"})
         sauce_connect(None, env_config)
         with sauce_connect:
             Popen.assert_called_once()
             args, kwargs = Popen.call_args
             cmd = args[0]
             assert "--tunnel-domains" in cmd
             i = cmd.index("--tunnel-domains")
             rest = cmd[i+1:]
             assert len(rest) >= 1
             if len(rest) > 1:
                 assert rest[1].startswith("-"), "--tunnel-domains takes a comma separated list (not a space separated list)"
-            assert set(rest[0].split(",")) == {"foo.bar.example.com", "example.net"}
+            assert set(rest[0].split(",")) == {'example.net',
+                                               'a.example.net',
+                                               'b.example.net'}
new file mode 100644
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptserve/tests/test_config.py
@@ -0,0 +1,339 @@
+import logging
+import os
+
+import pytest
+
+import localpaths
+
+config = pytest.importorskip("wptserve.config")
+
+
+def test_renamed_are_renamed():
+    assert len(set(config._renamed_props.viewkeys()) & set(config.Config._default.viewkeys())) == 0
+
+
+def test_renamed_exist():
+    assert set(config._renamed_props.viewvalues()).issubset(set(config.Config._default.viewkeys()))
+
+
+@pytest.mark.parametrize("base, override, expected", [
+    ({"a": 1}, {"a": 2}, {"a": 2}),
+    ({"a": 1}, {"b": 2}, {"a": 1}),
+    ({"a": {"b": 1}}, {"a": {}}, {"a": {"b": 1}}),
+    ({"a": {"b": 1}}, {"a": {"b": 2}}, {"a": {"b": 2}}),
+    ({"a": {"b": 1}}, {"a": {"b": 2, "c": 3}}, {"a": {"b": 2}}),
+    pytest.param({"a": {"b": 1}}, {"a": 2}, {"a": 1}, marks=pytest.mark.xfail),
+    pytest.param({"a": 1}, {"a": {"b": 2}}, {"a": 1}, marks=pytest.mark.xfail),
+])
+def test_merge_dict(base, override, expected):
+    assert expected == config._merge_dict(base, override)
+
+
+def test_logger_created():
+    c = config.Config()
+    assert c.logger is not None
+
+
+def test_logger_preserved():
+    logger = logging.getLogger("test_logger_preserved")
+    logger.setLevel(logging.DEBUG)
+
+    c = config.Config(logger=logger)
+    assert c.logger is logger
+
+
+def test_init_basic_prop():
+    c = config.Config(browser_host="foo.bar")
+    assert c.browser_host == "foo.bar"
+
+
+def test_init_prefixed_prop():
+    c = config.Config(doc_root="/")
+    assert c._doc_root == "/"
+
+
+def test_init_renamed_host():
+    logger = logging.getLogger("test_init_renamed_host")
+    logger.setLevel(logging.DEBUG)
+    handler = logging.handlers.BufferingHandler(100)
+    logger.addHandler(handler)
+
+    c = config.Config(logger=logger, host="foo.bar")
+    assert c.logger is logger
+    assert len(handler.buffer) == 1
+    assert "browser_host" in handler.buffer[0].getMessage()  # check we give the new name in the message
+    assert not hasattr(c, "host")
+    assert c.browser_host == "foo.bar"
+
+
+def test_init_bogus():
+    with pytest.raises(TypeError) as e:
+        config.Config(foo=1, bar=2)
+    assert "foo" in e.value.message
+    assert "bar" in e.value.message
+
+
+def test_getitem():
+    c = config.Config(browser_host="foo.bar")
+    assert c["browser_host"] == "foo.bar"
+
+
+def test_no_setitem():
+    c = config.Config()
+    with pytest.raises(TypeError):
+        c["browser_host"] = "foo.bar"
+
+
+def test_iter():
+    c = config.Config()
+    s = set(iter(c))
+    assert "browser_host" in s
+    assert "host" not in s
+    assert "__getitem__" not in s
+    assert "_browser_host" not in s
+
+
+def test_assignment():
+    c = config.Config()
+    c.browser_host = "foo.bar"
+    assert c.browser_host == "foo.bar"
+
+
+def test_update_basic():
+    c = config.Config()
+    c.update({"browser_host": "foo.bar"})
+    assert c.browser_host == "foo.bar"
+
+
+def test_update_prefixed():
+    c = config.Config()
+    c.update({"doc_root": "/"})
+    assert c._doc_root == "/"
+
+
+def test_update_renamed_host():
+    logger = logging.getLogger("test_update_renamed_host")
+    logger.setLevel(logging.DEBUG)
+    handler = logging.handlers.BufferingHandler(100)
+    logger.addHandler(handler)
+
+    c = config.Config(logger=logger)
+    assert c.logger is logger
+    assert len(handler.buffer) == 0
+
+    c.update({"host": "foo.bar"})
+
+    assert len(handler.buffer) == 1
+    assert "browser_host" in handler.buffer[0].getMessage()  # check we give the new name in the message
+    assert not hasattr(c, "host")
+    assert c.browser_host == "foo.bar"
+
+
+def test_update_bogus():
+    c = config.Config()
+    with pytest.raises(KeyError):
+        c.update({"foobar": 1})
+
+
+def test_ports_auto():
+    c = config.Config(ports={"http": ["auto"]},
+                      ssl={"type": "none"})
+    ports = c.ports
+    assert set(ports.keys()) == {"http"}
+    assert len(ports["http"]) == 1
+    assert isinstance(ports["http"][0], int)
+
+
+def test_ports_auto_mutate():
+    c = config.Config(ports={"http": [1001]},
+                      ssl={"type": "none"})
+    orig_ports = c.ports
+    assert set(orig_ports.keys()) == {"http"}
+    assert orig_ports["http"] == [1001]
+
+    c.ports = {"http": ["auto"]}
+    new_ports = c.ports
+    assert set(new_ports.keys()) == {"http"}
+    assert len(new_ports["http"]) == 1
+    assert isinstance(new_ports["http"][0], int)
+
+
+def test_ports_auto_roundtrip():
+    c = config.Config(ports={"http": ["auto"]},
+                      ssl={"type": "none"})
+    old_ports = c.ports
+    c.ports = old_ports
+    new_ports = c.ports
+    assert old_ports == new_ports
+
+
+def test_ports_idempotent():
+    c = config.Config(ports={"http": ["auto"]},
+                      ssl={"type": "none"})
+    ports_a = c.ports
+    ports_b = c.ports
+    assert ports_a == ports_b
+
+
+def test_ports_explicit():
+    c = config.Config(ports={"http": [1001]},
+                      ssl={"type": "none"})
+    ports = c.ports
+    assert set(ports.keys()) == {"http"}
+    assert ports["http"] == [1001]
+
+
+def test_ports_no_ssl():
+    c = config.Config(ports={"http": [1001], "https": [1002], "ws": [1003], "wss": [1004]},
+                      ssl={"type": "none"})
+    ports = c.ports
+    assert set(ports.keys()) == {"http", "https", "ws", "wss"}
+    assert ports["http"] == [1001]
+    assert ports["https"] == [None]
+    assert ports["ws"] == [1003]
+    assert ports["wss"] == [None]
+
+
+def test_ports_openssl():
+    c = config.Config(ports={"http": [1001], "https": [1002], "ws": [1003], "wss": [1004]},
+                      ssl={"type": "openssl"})
+    ports = c.ports
+    assert set(ports.keys()) == {"http", "https", "ws", "wss"}
+    assert ports["http"] == [1001]
+    assert ports["https"] == [1002]
+    assert ports["ws"] == [1003]
+    assert ports["wss"] == [1004]
+
+
+def test_doc_root_default():
+    c = config.Config()
+    assert c.doc_root == localpaths.repo_root
+
+
+def test_init_doc_root():
+    c = config.Config(doc_root="/")
+    assert c._doc_root == "/"
+    assert c.doc_root == "/"
+
+
+def test_set_doc_root():
+    c = config.Config()
+    c.doc_root = "/"
+    assert c._doc_root == "/"
+    assert c.doc_root == "/"
+
+
+def test_ws_doc_root_default():
+    c = config.Config()
+    assert c.ws_doc_root == os.path.join(localpaths.repo_root, "websockets", "handlers")
+
+
+def test_ws_doc_root_from_doc_root():
+    c = config.Config(doc_root="/foo")
+    assert c.ws_doc_root == os.path.join("/foo", "websockets", "handlers")
+
+
+def test_init_ws_doc_root():
+    c = config.Config(ws_doc_root="/")
+    assert c.doc_root == localpaths.repo_root  # check this hasn't changed
+    assert c._ws_doc_root == "/"
+    assert c.ws_doc_root == "/"
+
+
+def test_set_ws_doc_root():
+    c = config.Config()
+    c.ws_doc_root = "/"
+    assert c.doc_root == localpaths.repo_root  # check this hasn't changed
+    assert c._ws_doc_root == "/"
+    assert c.ws_doc_root == "/"
+
+
+def test_server_host_from_browser_host():
+    c = config.Config(browser_host="foo.bar")
+    assert c.server_host == "foo.bar"
+
+
+def test_init_server_host():
+    c = config.Config(server_host="foo.bar")
+    assert c.browser_host == "web-platform.test"  # check this hasn't changed
+    assert c._server_host == "foo.bar"
+    assert c.server_host == "foo.bar"
+
+
+def test_set_server_host():
+    c = config.Config()
+    c.server_host = "/"
+    assert c.browser_host == "web-platform.test"  # check this hasn't changed
+    assert c._server_host == "/"
+    assert c.server_host == "/"
+
+
+def test_domains():
+    c = config.Config(browser_host="foo.bar",
+                      subdomains={"a", "b"},
+                      not_subdomains={"x", "y"})
+    domains = c.domains
+    assert domains == {
+        "": "foo.bar",
+        "a": "a.foo.bar",
+        "b": "b.foo.bar",
+    }
+
+
+def test_not_domains():
+    c = config.Config(browser_host="foo.bar",
+                      subdomains={"a", "b"},
+                      not_subdomains={"x", "y"})
+    not_domains = c.not_domains
+    assert not_domains == {
+        "x": "x.foo.bar",
+        "y": "y.foo.bar",
+    }
+
+
+def test_domains_not_domains_intersection():
+    c = config.Config(browser_host="foo.bar",
+                      subdomains={"a", "b"},
+                      not_subdomains={"x", "y"})
+    domains = c.domains
+    not_domains = c.not_domains
+    assert len(set(domains.iterkeys()) & set(not_domains.iterkeys())) == 0
+    assert len(set(domains.itervalues()) & set(not_domains.itervalues())) == 0
+
+
+def test_all_domains():
+    c = config.Config(browser_host="foo.bar",
+                      subdomains={"a", "b"},
+                      not_subdomains={"x", "y"})
+    all_domains = c.all_domains
+    assert all_domains == {
+        "": "foo.bar",
+        "a": "a.foo.bar",
+        "b": "b.foo.bar",
+        "x": "x.foo.bar",
+        "y": "y.foo.bar",
+    }
+
+
+def test_ssl_env_override():
+    c = config.Config(override_ssl_env="foobar")
+    assert c.ssl_env == "foobar"
+
+
+def test_ssl_env_none():
+    c = config.Config(ssl={"type": "none"})
+    assert c.ssl_env is not None
+    assert c.ssl_env.ssl_enabled is False
+
+
+def test_ssl_env_openssl():
+    c = config.Config(ssl={"type": "openssl", "openssl": {"openssl_binary": "foobar"}})
+    assert c.ssl_env is not None
+    assert c.ssl_env.ssl_enabled is True
+    assert c.ssl_env.binary == "foobar"
+
+
+def test_ssl_env_bogus():
+    c = config.Config(ssl={"type": "foobar"})
+    with pytest.raises(ValueError):
+        c.ssl_env
new file mode 100644
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptserve/wptserve/config.py
@@ -0,0 +1,216 @@
+import json
+import logging
+import os
+
+from collections import defaultdict, Mapping
+
+import sslutils
+
+from localpaths import repo_root
+
+from .utils import get_port
+
+
+_renamed_props = {
+    "host": "browser_host",
+    "bind_hostname": "bind_address",
+}
+
+
+def _merge_dict(base_dict, override_dict):
+    rv = base_dict.copy()
+    for key, value in base_dict.iteritems():
+        if key in override_dict:
+            if isinstance(value, dict):
+                rv[key] = _merge_dict(value, override_dict[key])
+            else:
+                rv[key] = override_dict[key]
+    return rv
+
+
+class Config(Mapping):
+    """wptserve config
+
+    Inherits from Mapping for backwards compatibility with the old dict-based config"""
+
+    with open(os.path.join(repo_root, "config.default.json"), "rb") as _fp:
+        _default = json.load(_fp)
+
+    def __init__(self,
+                 logger=None,
+                 subdomains=set(),
+                 not_subdomains=set(),
+                 **kwargs):
+        if logger is None:
+            logger = logging.getLogger("web-platform-tests")
+            logger.setLevel(getattr(logging, kwargs.get("log_level", "debug").upper()))
+            self.logger = logger
+        else:
+            self.logger = logger
+
+        for k, v in self._default.iteritems():
+            setattr(self, k, kwargs.pop(k, v))
+
+        self.subdomains = subdomains
+        self.not_subdomains = not_subdomains
+
+        for k, new_k in _renamed_props.iteritems():
+            if k in kwargs:
+                self.logger.warning(
+                    "%s in config is deprecated; use %s instead" % (
+                        k,
+                        new_k
+                    )
+                )
+                setattr(self, new_k, kwargs.pop(k))
+
+        self.override_ssl_env = kwargs.pop("override_ssl_env", None)
+
+        if kwargs:
+            raise TypeError("__init__() got unexpected keyword arguments %r" % (tuple(kwargs),))
+
+    def __getitem__(self, k):
+        try:
+            return getattr(self, k)
+        except AttributeError:
+            raise KeyError(k)
+
+    def __iter__(self):
+        return iter([x for x in dir(self) if not x.startswith("_")])
+
+    def __len__(self):
+        return len([x for x in dir(self) if not x.startswith("_")])
+
+    def update(self, override):
+        """Load an overrides dict to override config values"""
+        override = override.copy()
+
+        for k in self._default:
+            if k in override:
+                self._set_override(k, override.pop(k))
+
+        for k, new_k in _renamed_props.iteritems():
+            if k in override:
+                self.logger.warning(
+                    "%s in config is deprecated; use %s instead" % (
+                        k,
+                        new_k
+                    )
+                )
+                self._set_override(new_k, override.pop(k))
+
+        if override:
+            k = next(iter(override))
+            raise KeyError("unknown config override '%s'" % k)
+
+    def _set_override(self, k, v):
+        old_v = getattr(self, k)
+        if isinstance(old_v, dict):
+            setattr(self, k, _merge_dict(old_v, v))
+        else:
+            setattr(self, k, v)
+
+    @property
+    def ports(self):
+        try:
+            old_ports = self._computed_ports
+        except AttributeError:
+            old_ports = {}
+
+        self._computed_ports = defaultdict(list)
+
+        for scheme, ports in self._ports.iteritems():
+            for i, port in enumerate(ports):
+                if scheme in ["wss", "https"] and not self.ssl_env.ssl_enabled:
+                    port = None
+                if port == "auto":
+                    try:
+                        port = old_ports[scheme][i]
+                    except (KeyError, IndexError):
+                        port = get_port(self.server_host)
+                else:
+                    port = port
+                self._computed_ports[scheme].append(port)
+
+        return self._computed_ports
+
+    @ports.setter
+    def ports(self, v):
+        self._ports = v
+
+    @property
+    def doc_root(self):
+        return self._doc_root if self._doc_root is not None else repo_root
+
+    @doc_root.setter
+    def doc_root(self, v):
+        self._doc_root = v
+
+    @property
+    def ws_doc_root(self):
+        if self._ws_doc_root is not None:
+            return self._ws_doc_root
+        else:
+            return os.path.join(self.doc_root, "websockets", "handlers")
+
+    @ws_doc_root.setter
+    def ws_doc_root(self, v):
+        self._ws_doc_root = v
+
+    @property
+    def server_host(self):
+        return self._server_host if self._server_host is not None else self.browser_host
+
+    @server_host.setter
+    def server_host(self, v):
+        self._server_host = v
+
+    @property
+    def domains(self):
+        assert self.browser_host.encode("idna") == self.browser_host
+        domains = {subdomain: (subdomain.encode("idna") + u"." + self.browser_host)
+                   for subdomain in self.subdomains}
+        domains[""] = self.browser_host
+        return domains
+
+    @property
+    def not_domains(self):
+        assert self.browser_host.encode("idna") == self.browser_host
+        domains = {subdomain: (subdomain.encode("idna") + u"." + self.browser_host)
+                   for subdomain in self.not_subdomains}
+        return domains
+
+    @property
+    def all_domains(self):
+        domains = self.domains.copy()
+        domains.update(self.not_domains)
+        return domains
+
+    @property
+    def ssl_env(self):
+        try:
+            if self.override_ssl_env is not None:
+                return self.override_ssl_env
+        except AttributeError:
+            pass
+
+        implementation_type = self.ssl["type"]
+
+        try:
+            cls = sslutils.environments[implementation_type]
+        except KeyError:
+            raise ValueError("%s is not a vaid ssl type." % implementation_type)
+        kwargs = self.ssl.get(implementation_type, {}).copy()
+        return cls(self.logger, **kwargs)
+
+    @property
+    def paths(self):
+        return {"doc_root": self.doc_root,
+                "ws_doc_root": self.ws_doc_root}
+
+    @property
+    def ssl_config(self):
+        key_path, cert_path = self.ssl_env.host_cert_path(self.domains)
+        return {"key_path": key_path,
+                "cert_path": cert_path,
+                "encrypt_after_connect": self.ssl["encrypt_after_connect"]}
--- a/testing/web-platform/tests/tools/wptserve/wptserve/utils.py
+++ b/testing/web-platform/tests/tools/wptserve/wptserve/utils.py
@@ -1,14 +1,106 @@
+import socket
+
 def invert_dict(dict):
     rv = {}
     for key, values in dict.iteritems():
         for value in values:
             if value in rv:
                 raise ValueError
             rv[value] = key
     return rv
 
 
 class HTTPException(Exception):
     def __init__(self, code, message=""):
         self.code = code
         self.message = message
+
+
+def _open_socket(host, port):
+    sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+    if port != 0:
+        sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
+    sock.bind((host, port))
+    sock.listen(5)
+    return sock
+
+def is_bad_port(port):
+    """
+    Bad port as per https://fetch.spec.whatwg.org/#port-blocking
+    """
+    return port in [
+        1,     # tcpmux
+        7,     # echo
+        9,     # discard
+        11,    # systat
+        13,    # daytime
+        15,    # netstat
+        17,    # qotd
+        19,    # chargen
+        20,    # ftp-data
+        21,    # ftp
+        22,    # ssh
+        23,    # telnet
+        25,    # smtp
+        37,    # time
+        42,    # name
+        43,    # nicname
+        53,    # domain
+        77,    # priv-rjs
+        79,    # finger
+        87,    # ttylink
+        95,    # supdup
+        101,   # hostriame
+        102,   # iso-tsap
+        103,   # gppitnp
+        104,   # acr-nema
+        109,   # pop2
+        110,   # pop3
+        111,   # sunrpc
+        113,   # auth
+        115,   # sftp
+        117,   # uucp-path
+        119,   # nntp
+        123,   # ntp
+        135,   # loc-srv / epmap
+        139,   # netbios
+        143,   # imap2
+        179,   # bgp
+        389,   # ldap
+        465,   # smtp+ssl
+        512,   # print / exec
+        513,   # login
+        514,   # shell
+        515,   # printer
+        526,   # tempo
+        530,   # courier
+        531,   # chat
+        532,   # netnews
+        540,   # uucp
+        556,   # remotefs
+        563,   # nntp+ssl
+        587,   # smtp
+        601,   # syslog-conn
+        636,   # ldap+ssl
+        993,   # imap+ssl
+        995,   # pop3+ssl
+        2049,  # nfs
+        3659,  # apple-sasl
+        4045,  # lockd
+        6000,  # x11
+        6665,  # irc (alternate)
+        6666,  # irc (alternate)
+        6667,  # irc (default)
+        6668,  # irc (alternate)
+        6669,  # irc (alternate)
+    ]
+
+def get_port(host):
+    port = 0
+    while True:
+        free_socket = _open_socket(host, 0)
+        port = free_socket.getsockname()[1]
+        free_socket.close()
+        if not is_bad_port(port):
+            break
+    return port