diff options
Diffstat (limited to 'testing/web-platform/tests/tools/serve')
-rw-r--r-- | testing/web-platform/tests/tools/serve/__init__.py | 1 | ||||
-rw-r--r-- | testing/web-platform/tests/tools/serve/commands.json | 17 | ||||
-rw-r--r-- | testing/web-platform/tests/tools/serve/serve.py | 1237 | ||||
-rw-r--r-- | testing/web-platform/tests/tools/serve/test_functional.py | 81 | ||||
-rw-r--r-- | testing/web-platform/tests/tools/serve/test_serve.py | 149 | ||||
-rw-r--r-- | testing/web-platform/tests/tools/serve/wave.py | 134 |
6 files changed, 1619 insertions, 0 deletions
diff --git a/testing/web-platform/tests/tools/serve/__init__.py b/testing/web-platform/tests/tools/serve/__init__.py new file mode 100644 index 0000000000..40da7e9fd3 --- /dev/null +++ b/testing/web-platform/tests/tools/serve/__init__.py @@ -0,0 +1 @@ +from . import serve # noqa: F401 diff --git a/testing/web-platform/tests/tools/serve/commands.json b/testing/web-platform/tests/tools/serve/commands.json new file mode 100644 index 0000000000..ed1d72e60e --- /dev/null +++ b/testing/web-platform/tests/tools/serve/commands.json @@ -0,0 +1,17 @@ +{ + "serve": { + "path": "serve.py", + "script": "run", + "parser": "get_parser", + "help": "Run wptserve server", + "virtualenv": false + }, + "serve-wave": { + "path": "wave.py", + "script": "run", + "parser": "get_parser", + "help": "Run wptserve server for WAVE", + "virtualenv": true, + "requirements": ["../wave/requirements.txt"] + } +} diff --git a/testing/web-platform/tests/tools/serve/serve.py b/testing/web-platform/tests/tools/serve/serve.py new file mode 100644 index 0000000000..6eba2153a1 --- /dev/null +++ b/testing/web-platform/tests/tools/serve/serve.py @@ -0,0 +1,1237 @@ +# mypy: allow-untyped-defs + +import abc +import argparse +import importlib +import json +import logging +import multiprocessing +import os +import platform +import subprocess +import sys +import threading +import time +import traceback +import urllib +import uuid +from collections import defaultdict, OrderedDict +from io import IOBase +from itertools import chain, product +from html5lib import html5parser +from typing import ClassVar, List, Set, Tuple + +from localpaths import repo_root # type: ignore + +from manifest.sourcefile import read_script_metadata, js_meta_re, parse_variants # type: ignore +from wptserve import server as wptserve, handlers +from wptserve import stash +from wptserve import config +from wptserve.handlers import filesystem_path, wrap_pipeline +from wptserve.response import ResponseHeaders +from wptserve.utils import get_port, HTTPException, http2_compatible +from mod_pywebsocket import standalone as pywebsocket + + +EDIT_HOSTS_HELP = ("Please ensure all the necessary WPT subdomains " + "are mapped to a loopback device in /etc/hosts.\n" + "See https://web-platform-tests.org/running-tests/from-local-system.html#system-setup " + "for instructions.") + + +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) + return s[:-len(old)] + new + + +def domains_are_distinct(a, b): + a_parts = a.split(".") + b_parts = b.split(".") + min_length = min(len(a_parts), len(b_parts)) + slice_index = -1 * min_length + + return a_parts[slice_index:] != b_parts[slice_index:] + + +def inject_script(html, script_tag): + # Tokenize and find the position of the first content (e.g. after the + # doctype, html, and head opening tags if present but before any other tags). + token_types = html5parser.tokenTypes + after_tags = {"html", "head"} + before_tokens = {token_types["EndTag"], token_types["EmptyTag"], + token_types["Characters"]} + error_tokens = {token_types["ParseError"]} + + tokenizer = html5parser._tokenizer.HTMLTokenizer(html) + stream = tokenizer.stream + offset = 0 + error = False + for item in tokenizer: + if item["type"] == token_types["StartTag"]: + if not item["name"].lower() in after_tags: + break + elif item["type"] in before_tokens: + break + elif item["type"] in error_tokens: + error = True + break + offset = stream.chunkOffset + else: + error = True + + if not error and stream.prevNumCols or stream.prevNumLines: + # We're outside the first chunk, so we don't know what to do + error = True + + if error: + return html + else: + return html[:offset] + script_tag + html[offset:] + + +class WrapperHandler: + + __meta__ = abc.ABCMeta + + headers = [] # type: ClassVar[List[Tuple[str, str]]] + + def __init__(self, base_path=None, url_base="/"): + self.base_path = base_path + self.url_base = url_base + self.handler = handlers.handler(self.handle_request) + + def __call__(self, request, response): + self.handler(request, response) + + def handle_request(self, request, response): + headers = self.headers + handlers.load_headers( + request, self._get_filesystem_path(request)) + for header_name, header_value in headers: + response.headers.set(header_name, header_value) + + self.check_exposure(request) + + path = self._get_path(request.url_parts.path, True) + query = request.url_parts.query + if query: + query = "?" + query + meta = "\n".join(self._get_meta(request)) + script = "\n".join(self._get_script(request)) + response.content = self.wrapper % {"meta": meta, "script": script, "path": path, "query": query} + wrap_pipeline(path, request, response) + + def _get_path(self, path, resource_path): + """Convert the path from an incoming request into a path corresponding to an "unwrapped" + resource e.g. the file on disk that will be loaded in the wrapper. + + :param path: Path from the HTTP request + :param resource_path: Boolean used to control whether to get the path for the resource that + this wrapper will load or the associated file on disk. + Typically these are the same but may differ when there are multiple + layers of wrapping e.g. for a .any.worker.html input the underlying disk file is + .any.js but the top level html file loads a resource with a + .any.worker.js extension, which itself loads the .any.js file. + If True return the path to the resource that the wrapper will load, + otherwise return the path to the underlying file on disk.""" + for item in self.path_replace: + if len(item) == 2: + src, dest = item + else: + assert len(item) == 3 + src = item[0] + dest = item[2 if resource_path else 1] + if path.endswith(src): + path = replace_end(path, src, dest) + return path + + def _get_filesystem_path(self, request): + """Get the path of the underlying resource file on disk.""" + return self._get_path(filesystem_path(self.base_path, request, self.url_base), False) + + def _get_metadata(self, request): + """Get an iterator over script metadata based on // META comments in the + associated js file. + + :param request: The Request being processed. + """ + path = self._get_filesystem_path(request) + try: + with open(path, "rb") as f: + yield from read_script_metadata(f, js_meta_re) + except OSError: + raise HTTPException(404) + + def _get_meta(self, request): + """Get an iterator over strings to inject into the wrapper document + based on // META comments in the associated js file. + + :param request: The Request being processed. + """ + for key, value in self._get_metadata(request): + replacement = self._meta_replacement(key, value) + if replacement: + yield replacement + + def _get_script(self, request): + """Get an iterator over strings to inject into the wrapper document + based on // META comments in the associated js file. + + :param request: The Request being processed. + """ + for key, value in self._get_metadata(request): + replacement = self._script_replacement(key, value) + if replacement: + yield replacement + + @abc.abstractproperty + def path_replace(self): + # A list containing a mix of 2 item tuples with (input suffix, output suffix) + # and 3-item tuples with (input suffix, filesystem suffix, resource suffix) + # for the case where we want a different path in the generated resource to + # the actual path on the filesystem (e.g. when there is another handler + # that will wrap the file). + return None + + @abc.abstractproperty + def wrapper(self): + # String template with variables path and meta for wrapper document + return None + + @abc.abstractmethod + def _meta_replacement(self, key, value): + # Get the string to insert into the wrapper document, given + # a specific metadata key: value pair. + pass + + @abc.abstractmethod + def check_exposure(self, request): + # Raise an exception if this handler shouldn't be exposed after all. + pass + + +class HtmlWrapperHandler(WrapperHandler): + global_type = None # type: ClassVar[str] + headers = [('Content-Type', 'text/html')] + + def check_exposure(self, request): + if self.global_type: + globals = "" + for (key, value) in self._get_metadata(request): + if key == "global": + globals = value + break + + if self.global_type not in parse_variants(globals): + raise HTTPException(404, "This test cannot be loaded in %s mode" % + self.global_type) + + def _meta_replacement(self, key, value): + if key == "timeout": + if value == "long": + return '<meta name="timeout" content="long">' + if key == "title": + value = value.replace("&", "&").replace("<", "<") + return '<title>%s</title>' % value + return None + + def _script_replacement(self, key, value): + if key == "script": + attribute = value.replace("&", "&").replace('"', """) + return '<script src="%s"></script>' % attribute + return None + + +class HtmlScriptInjectorHandlerWrapper: + def __init__(self, inject="", wrap=None): + self.inject = inject + self.wrap = wrap + + def __call__(self, request, response): + self.wrap(request, response) + # If the response content type isn't html, don't modify it. + if not isinstance(response.headers, ResponseHeaders) or response.headers.get("Content-Type")[0] != b"text/html": + return response + + # Skip injection on custom streaming responses. + if not isinstance(response.content, (bytes, str, IOBase)) and not hasattr(response, "read"): + return response + + response.content = inject_script( + b"".join(response.iter_content(read_file=True)), + b"<script>\n" + + self.inject + b"\n" + + (b"// Remove the injected script tag from the DOM.\n" + b"document.currentScript.remove();\n" + b"</script>\n")) + return response + + +class WorkersHandler(HtmlWrapperHandler): + global_type = "dedicatedworker" + path_replace = [(".any.worker.html", ".any.js", ".any.worker.js"), + (".worker.html", ".worker.js")] + wrapper = """<!doctype html> +<meta charset=utf-8> +%(meta)s +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<div id=log></div> +<script> +fetch_tests_from_worker(new Worker("%(path)s%(query)s")); +</script> +""" + + +class WorkerModulesHandler(HtmlWrapperHandler): + global_type = "dedicatedworker-module" + path_replace = [(".any.worker-module.html", ".any.js", ".any.worker-module.js"), + (".worker.html", ".worker.js")] + wrapper = """<!doctype html> +<meta charset=utf-8> +%(meta)s +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<div id=log></div> +<script> +fetch_tests_from_worker(new Worker("%(path)s%(query)s", { type: "module" })); +</script> +""" + + +class WindowHandler(HtmlWrapperHandler): + path_replace = [(".window.html", ".window.js")] + wrapper = """<!doctype html> +<meta charset=utf-8> +%(meta)s +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +%(script)s +<div id=log></div> +<script src="%(path)s"></script> +""" + + +class AnyHtmlHandler(HtmlWrapperHandler): + global_type = "window" + path_replace = [(".any.html", ".any.js")] + wrapper = """<!doctype html> +<meta charset=utf-8> +%(meta)s +<script> +self.GLOBAL = { + isWindow: function() { return true; }, + isWorker: function() { return false; }, + isShadowRealm: function() { return false; }, +}; +</script> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +%(script)s +<div id=log></div> +<script src="%(path)s"></script> +""" + + +class SharedWorkersHandler(HtmlWrapperHandler): + global_type = "sharedworker" + path_replace = [(".any.sharedworker.html", ".any.js", ".any.worker.js")] + wrapper = """<!doctype html> +<meta charset=utf-8> +%(meta)s +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<div id=log></div> +<script> +fetch_tests_from_worker(new SharedWorker("%(path)s%(query)s")); +</script> +""" + + +class SharedWorkerModulesHandler(HtmlWrapperHandler): + global_type = "sharedworker-module" + path_replace = [(".any.sharedworker-module.html", ".any.js", ".any.worker-module.js")] + wrapper = """<!doctype html> +<meta charset=utf-8> +%(meta)s +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<div id=log></div> +<script> +fetch_tests_from_worker(new SharedWorker("%(path)s%(query)s", { type: "module" })); +</script> +""" + + +class ServiceWorkersHandler(HtmlWrapperHandler): + global_type = "serviceworker" + path_replace = [(".any.serviceworker.html", ".any.js", ".any.worker.js")] + wrapper = """<!doctype html> +<meta charset=utf-8> +%(meta)s +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<div id=log></div> +<script> +(async function() { + const scope = 'does/not/exist'; + let reg = await navigator.serviceWorker.getRegistration(scope); + if (reg) await reg.unregister(); + reg = await navigator.serviceWorker.register("%(path)s%(query)s", {scope}); + fetch_tests_from_worker(reg.installing); +})(); +</script> +""" + + +class ServiceWorkerModulesHandler(HtmlWrapperHandler): + global_type = "serviceworker-module" + path_replace = [(".any.serviceworker-module.html", + ".any.js", ".any.worker-module.js")] + wrapper = """<!doctype html> +<meta charset=utf-8> +%(meta)s +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<div id=log></div> +<script> +(async function() { + const scope = 'does/not/exist'; + let reg = await navigator.serviceWorker.getRegistration(scope); + if (reg) await reg.unregister(); + reg = await navigator.serviceWorker.register( + "%(path)s%(query)s", + { scope, type: 'module' }, + ); + fetch_tests_from_worker(reg.installing); +})(); +</script> +""" + +class ShadowRealmHandler(HtmlWrapperHandler): + global_type = "shadowrealm" + path_replace = [(".any.shadowrealm.html", ".any.js")] + + wrapper = """<!doctype html> +<meta charset=utf-8> +%(meta)s +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script> +(async function() { + const r = new ShadowRealm(); + + await new Promise(r.evaluate(` + (resolve, reject) => { + (async () => { + globalThis.self.GLOBAL = { + isWindow: function() { return false; }, + isWorker: function() { return false; }, + isShadowRealm: function() { return true; }, + }; + await import("/resources/testharness.js"); + %(script)s + await import("%(path)s"); + })().then(resolve, (e) => reject(e.toString())); + } + `)); + + await fetch_tests_from_shadow_realm(r); + done(); +})(); +</script> +""" + + def _script_replacement(self, key, value): + if key == "script": + return 'await import("%s");' % value + return None + + +class BaseWorkerHandler(WrapperHandler): + headers = [('Content-Type', 'text/javascript')] + + def _meta_replacement(self, key, value): + return None + + @abc.abstractmethod + def _create_script_import(self, attribute): + # Take attribute (a string URL to a JS script) and return JS source to import the script + # into the worker. + pass + + def _script_replacement(self, key, value): + if key == "script": + attribute = value.replace("\\", "\\\\").replace('"', '\\"') + return self._create_script_import(attribute) + if key == "title": + value = value.replace("\\", "\\\\").replace('"', '\\"') + return 'self.META_TITLE = "%s";' % value + return None + + +class ClassicWorkerHandler(BaseWorkerHandler): + path_replace = [(".any.worker.js", ".any.js")] + wrapper = """%(meta)s +self.GLOBAL = { + isWindow: function() { return false; }, + isWorker: function() { return true; }, + isShadowRealm: function() { return false; }, +}; +importScripts("/resources/testharness.js"); +%(script)s +importScripts("%(path)s"); +done(); +""" + + def _create_script_import(self, attribute): + return 'importScripts("%s")' % attribute + + +class ModuleWorkerHandler(BaseWorkerHandler): + path_replace = [(".any.worker-module.js", ".any.js")] + wrapper = """%(meta)s +self.GLOBAL = { + isWindow: function() { return false; }, + isWorker: function() { return true; }, + isShadowRealm: function() { return false; }, +}; +import "/resources/testharness.js"; +%(script)s +import "%(path)s"; +done(); +""" + + def _create_script_import(self, attribute): + return 'import "%s";' % attribute + + +rewrites = [("GET", "/resources/WebIDLParser.js", "/resources/webidl2/lib/webidl2.js")] + + +class RoutesBuilder: + def __init__(self, inject_script = None): + 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)), + ("*", "{spec}/tools/*", handlers.ErrorHandler(404)), + ("*", "/results/", handlers.ErrorHandler(404))] + + self.extra = [] + self.inject_script_data = None + if inject_script is not None: + with open(inject_script, 'rb') as f: + self.inject_script_data = f.read() + + self.mountpoint_routes = OrderedDict() + + self.add_mount_point("/", None) + + def get_routes(self): + routes = self.forbidden_override + self.forbidden + self.extra + # Using reversed here means that mount points that are added later + # get higher priority. This makes sense since / is typically added + # first. + for item in reversed(self.mountpoint_routes.values()): + routes.extend(item) + return routes + + def add_handler(self, method, route, handler): + self.extra.append((str(method), str(route), handler)) + + def add_static(self, path, format_args, content_type, route, headers=None): + if headers is None: + headers = {} + handler = handlers.StaticHandler(path, format_args, content_type, **headers) + self.add_handler("GET", str(route), handler) + + def add_mount_point(self, url_base, path): + url_base = "/%s/" % url_base.strip("/") if url_base != "/" else "/" + + self.mountpoint_routes[url_base] = [] + + routes = [ + ("GET", "*.worker.html", WorkersHandler), + ("GET", "*.worker-module.html", WorkerModulesHandler), + ("GET", "*.window.html", WindowHandler), + ("GET", "*.any.html", AnyHtmlHandler), + ("GET", "*.any.sharedworker.html", SharedWorkersHandler), + ("GET", "*.any.sharedworker-module.html", SharedWorkerModulesHandler), + ("GET", "*.any.serviceworker.html", ServiceWorkersHandler), + ("GET", "*.any.serviceworker-module.html", ServiceWorkerModulesHandler), + ("GET", "*.any.shadowrealm.html", ShadowRealmHandler), + ("GET", "*.any.worker.js", ClassicWorkerHandler), + ("GET", "*.any.worker-module.js", ModuleWorkerHandler), + ("GET", "*.asis", handlers.AsIsHandler), + ("*", "/.well-known/attribution-reporting/report-event-attribution", handlers.PythonScriptHandler), + ("*", "/.well-known/attribution-reporting/debug/report-event-attribution", handlers.PythonScriptHandler), + ("*", "/.well-known/attribution-reporting/report-aggregate-attribution", handlers.PythonScriptHandler), + ("*", "/.well-known/attribution-reporting/debug/report-aggregate-attribution", handlers.PythonScriptHandler), + ("*", "/.well-known/attribution-reporting/debug/verbose", handlers.PythonScriptHandler), + ("*", "/.well-known/web-identity", handlers.PythonScriptHandler), + ("*", "*.py", handlers.PythonScriptHandler), + ("GET", "*", handlers.FileHandler) + ] + + for (method, suffix, handler_cls) in routes: + handler = handler_cls(base_path=path, url_base=url_base) + if self.inject_script_data is not None: + handler = HtmlScriptInjectorHandlerWrapper(inject=self.inject_script_data, wrap=handler) + + self.mountpoint_routes[url_base].append( + (method, + "%s%s" % (url_base if url_base != "/" else "", suffix), + handler)) + + def add_file_mount_point(self, file_url, base_path): + assert file_url.startswith("/") + url_base = file_url[0:file_url.rfind("/") + 1] + self.mountpoint_routes[file_url] = [("GET", file_url, handlers.FileHandler(base_path=base_path, url_base=url_base))] + + +def get_route_builder(logger, aliases, config): + builder = RoutesBuilder(config.inject_script) + for alias in aliases: + url = alias["url-path"] + directory = alias["local-dir"] + if not url.startswith("/") or len(directory) == 0: + logger.error("\"url-path\" value must start with '/'.") + continue + if url.endswith("/"): + builder.add_mount_point(url, directory) + else: + builder.add_file_mount_point(url, directory) + return builder + + +class ServerProc: + def __init__(self, mp_context, scheme=None): + self.proc = None + self.daemon = None + self.mp_context = mp_context + self.stop_flag = mp_context.Event() + self.scheme = scheme + + def start(self, init_func, host, port, paths, routes, bind_address, config, log_handlers, **kwargs): + self.proc = self.mp_context.Process(target=self.create_daemon, + args=(init_func, host, port, paths, routes, bind_address, + config, log_handlers), + name='%s on port %s' % (self.scheme, port), + kwargs=kwargs) + self.proc.daemon = True + self.proc.start() + + def create_daemon(self, init_func, host, port, paths, routes, bind_address, + config, log_handlers, **kwargs): + # Ensure that when we start this in a new process we have the global lock + # in the logging module unlocked + importlib.reload(logging) + + logger = get_logger(config.log_level, log_handlers) + + if sys.platform == "darwin": + # on Darwin, NOFILE starts with a very low limit (256), so bump it up a little + # by way of comparison, Debian starts with a limit of 1024, Windows 512 + import resource # local, as it only exists on Unix-like systems + maxfilesperproc = int(subprocess.check_output( + ["sysctl", "-n", "kern.maxfilesperproc"] + ).strip()) + soft, hard = resource.getrlimit(resource.RLIMIT_NOFILE) + # 2048 is somewhat arbitrary, but gives us some headroom for wptrunner --parallel + # note that it's expected that 2048 will be the min here + new_soft = min(2048, maxfilesperproc, hard) + if soft < new_soft: + resource.setrlimit(resource.RLIMIT_NOFILE, (new_soft, hard)) + try: + self.daemon = init_func(logger, host, port, paths, routes, bind_address, config, **kwargs) + except OSError: + logger.critical("Socket error on port %s" % port, file=sys.stderr) + raise + except Exception: + logger.critical(traceback.format_exc()) + raise + + if self.daemon: + try: + self.daemon.start() + try: + self.stop_flag.wait() + except KeyboardInterrupt: + pass + finally: + self.daemon.stop() + except Exception: + logger.critical(traceback.format_exc()) + raise + + def request_shutdown(self): + if self.is_alive(): + self.stop_flag.set() + + def wait(self, timeout=None): + self.proc.join(timeout) + + def is_alive(self): + return self.proc.is_alive() + + +def check_subdomains(logger, config, routes, mp_context, log_handlers): + paths = config.paths + bind_address = config.bind_address + + host = config.server_host + port = get_port() + logger.debug("Going to use port %d to check subdomains" % port) + + wrapper = ServerProc(mp_context) + wrapper.start(start_http_server, host, port, paths, routes, + bind_address, config, log_handlers) + + url = f"http://{host}:{port}/" + connected = False + for i in range(10): + try: + urllib.request.urlopen(url) + connected = True + break + except urllib.error.URLError: + time.sleep(1) + + if not connected: + logger.critical("Failed to connect to test server " + "on {}. {}".format(url, EDIT_HOSTS_HELP)) + sys.exit(1) + + for domain in config.domains_set: + if domain == host: + continue + + try: + urllib.request.urlopen("http://%s:%d/" % (domain, port)) + except Exception: + logger.critical(f"Failed probing domain {domain}. {EDIT_HOSTS_HELP}") + sys.exit(1) + + wrapper.request_shutdown() + wrapper.wait() + + +def make_hosts_file(config, host): + rv = [] + + for domain in config.domains_set: + rv.append("%s\t%s\n" % (host, domain)) + + # Windows interpets the IP address 0.0.0.0 as non-existent, making it an + # appropriate alias for non-existent hosts. However, UNIX-like systems + # interpret the same address to mean any IP address, which is inappropraite + # for this context. These systems do not reserve any value for this + # purpose, so the inavailability of the domains must be taken for granted. + # + # https://github.com/web-platform-tests/wpt/issues/10560 + if platform.uname()[0] == "Windows": + for not_domain in config.not_domains_set: + rv.append("0.0.0.0\t%s\n" % not_domain) + + return "".join(rv) + + +def start_servers(logger, host, ports, paths, routes, bind_address, config, + mp_context, log_handlers, **kwargs): + servers = defaultdict(list) + for scheme, ports in ports.items(): + assert len(ports) == {"http": 2, "https": 2}.get(scheme, 1) + + # If trying to start HTTP/2.0 server, check compatibility + if scheme == 'h2' and not http2_compatible(): + logger.error('Cannot start HTTP/2.0 server as the environment is not compatible. ' + + 'Requires OpenSSL 1.0.2+') + continue + + # Skip WebTransport over HTTP/3 server unless if is enabled explicitly. + if scheme == 'webtransport-h3' and not kwargs.get("webtransport_h3"): + continue + + for port in ports: + if port is None: + continue + + init_func = { + "http": start_http_server, + "http-private": start_http_server, + "http-public": start_http_server, + "https": start_https_server, + "https-private": start_https_server, + "https-public": start_https_server, + "h2": start_http2_server, + "ws": start_ws_server, + "wss": start_wss_server, + "webtransport-h3": start_webtransport_h3_server, + }[scheme] + + server_proc = ServerProc(mp_context, scheme=scheme) + server_proc.start(init_func, host, port, paths, routes, bind_address, + config, log_handlers, **kwargs) + servers[scheme].append((port, server_proc)) + + return servers + + +def startup_failed(logger): + logger.critical(EDIT_HOSTS_HELP) + sys.exit(1) + + +def start_http_server(logger, host, port, paths, routes, bind_address, config, **kwargs): + try: + return wptserve.WebTestHttpd(host=host, + port=port, + doc_root=paths["doc_root"], + routes=routes, + rewrites=rewrites, + bind_address=bind_address, + config=config, + use_ssl=False, + key_file=None, + certificate=None, + latency=kwargs.get("latency")) + except Exception: + startup_failed(logger) + + +def start_https_server(logger, host, port, paths, routes, bind_address, config, **kwargs): + try: + return wptserve.WebTestHttpd(host=host, + port=port, + doc_root=paths["doc_root"], + routes=routes, + rewrites=rewrites, + bind_address=bind_address, + config=config, + use_ssl=True, + key_file=config.ssl_config["key_path"], + certificate=config.ssl_config["cert_path"], + encrypt_after_connect=config.ssl_config["encrypt_after_connect"], + latency=kwargs.get("latency")) + except Exception: + startup_failed(logger) + + +def start_http2_server(logger, host, port, paths, routes, bind_address, config, **kwargs): + try: + return wptserve.WebTestHttpd(host=host, + port=port, + handler_cls=wptserve.Http2WebTestRequestHandler, + doc_root=paths["doc_root"], + ws_doc_root=paths["ws_doc_root"], + routes=routes, + rewrites=rewrites, + bind_address=bind_address, + config=config, + use_ssl=True, + key_file=config.ssl_config["key_path"], + certificate=config.ssl_config["cert_path"], + encrypt_after_connect=config.ssl_config["encrypt_after_connect"], + latency=kwargs.get("latency"), + http2=True) + except Exception: + startup_failed(logger) + + +class WebSocketDaemon: + def __init__(self, host, port, doc_root, handlers_root, bind_address, ssl_config): + logger = logging.getLogger() + self.host = host + cmd_args = ["-p", port, + "-d", doc_root, + "-w", handlers_root] + + if ssl_config is not None: + cmd_args += ["--tls", + "--private-key", ssl_config["key_path"], + "--certificate", ssl_config["cert_path"]] + + if (bind_address): + cmd_args = ["-H", host] + cmd_args + opts, args = pywebsocket._parse_args_and_config(cmd_args) + opts.cgi_directories = [] + opts.is_executable_method = None + self.server = pywebsocket.WebSocketServer(opts) + ports = [item[0].getsockname()[1] for item in self.server._sockets] + if not ports: + # TODO: Fix the logging configuration in WebSockets processes + # see https://github.com/web-platform-tests/wpt/issues/22719 + logger.critical("Failed to start websocket server on port %s, " + "is something already using that port?" % port, file=sys.stderr) + raise OSError() + assert all(item == ports[0] for item in ports) + self.port = ports[0] + self.started = False + self.server_thread = None + + def start(self): + self.started = True + self.server_thread = threading.Thread(target=self.server.serve_forever) + self.server_thread.setDaemon(True) # don't hang on exit + self.server_thread.start() + + def stop(self): + """ + Stops the server. + + If the server is not running, this method has no effect. + """ + if self.started: + try: + self.server.shutdown() + self.server.server_close() + self.server_thread.join() + self.server_thread = None + except AttributeError: + pass + self.started = False + self.server = None + + +def start_ws_server(logger, host, port, paths, routes, bind_address, config, **kwargs): + try: + return WebSocketDaemon(host, + str(port), + repo_root, + config.paths["ws_doc_root"], + bind_address, + ssl_config=None) + except Exception: + startup_failed(logger) + + +def start_wss_server(logger, host, port, paths, routes, bind_address, config, **kwargs): + try: + return WebSocketDaemon(host, + str(port), + repo_root, + config.paths["ws_doc_root"], + bind_address, + config.ssl_config) + except Exception: + startup_failed(logger) + + +def start_webtransport_h3_server(logger, host, port, paths, routes, bind_address, config, **kwargs): + try: + # TODO(bashi): Move the following import to the beginning of this file + # once WebTransportH3Server is enabled by default. + from webtransport.h3.webtransport_h3_server import WebTransportH3Server # type: ignore + return WebTransportH3Server(host=host, + port=port, + doc_root=paths["doc_root"], + cert_path=config.ssl_config["cert_path"], + key_path=config.ssl_config["key_path"], + logger=logger) + except Exception as error: + logger.critical( + f"Failed to start WebTransport over HTTP/3 server: {error}") + sys.exit(0) + + +def start(logger, config, routes, mp_context, log_handlers, **kwargs): + host = config["server_host"] + ports = config.ports + paths = config.paths + bind_address = config["bind_address"] + + logger.debug("Using ports: %r" % ports) + + servers = start_servers(logger, host, ports, paths, routes, bind_address, config, mp_context, + log_handlers, **kwargs) + + return servers + + +def iter_servers(servers): + for servers in servers.values(): + for port, server in servers: + yield server + + +def _make_subdomains_product(s: Set[str], depth: int = 2) -> Set[str]: + return {".".join(x) for x in chain(*(product(s, repeat=i) for i in range(1, depth+1)))} + + +_subdomains = {"www", + "www1", + "www2", + "天気の良い日", + "élève"} + +_not_subdomains = {"nonexistent"} + +_subdomains = _make_subdomains_product(_subdomains) + +_not_subdomains = _make_subdomains_product(_not_subdomains) + + +class ConfigBuilder(config.ConfigBuilder): + """serve config + + This subclasses wptserve.config.ConfigBuilder to add serve config options. + """ + + _default = { + "browser_host": "web-platform.test", + "alternate_hosts": { + "alt": "not-web-platform.test" + }, + "doc_root": repo_root, + "ws_doc_root": os.path.join(repo_root, "websockets", "handlers"), + "server_host": None, + "ports": { + "http": [8000, "auto"], + "http-private": ["auto"], + "http-public": ["auto"], + "https": [8443, 8444], + "https-private": ["auto"], + "https-public": ["auto"], + "ws": ["auto"], + "wss": ["auto"], + "webtransport-h3": ["auto"], + }, + "check_subdomains": True, + "log_level": "info", + "bind_address": True, + "ssl": { + "type": "pregenerated", + "encrypt_after_connect": False, + "openssl": { + "openssl_binary": "openssl", + "base_path": "_certs", + "password": "web-platform-tests", + "force_regenerate": False, + "duration": 30, + "base_conf_path": None + }, + "pregenerated": { + "host_key_path": os.path.join(repo_root, "tools", "certs", "web-platform.test.key"), + "host_cert_path": os.path.join(repo_root, "tools", "certs", "web-platform.test.pem") + }, + "none": {} + }, + "aliases": [] + } + + computed_properties = ["ws_doc_root"] + config.ConfigBuilder.computed_properties + + def __init__(self, logger, *args, **kwargs): + if "subdomains" not in kwargs: + kwargs["subdomains"] = _subdomains + if "not_subdomains" not in kwargs: + kwargs["not_subdomains"] = _not_subdomains + super().__init__( + logger, + *args, + **kwargs + ) + with self as c: + browser_host = c.get("browser_host") + alternate_host = c.get("alternate_hosts", {}).get("alt") + + if not domains_are_distinct(browser_host, alternate_host): + raise ValueError( + "Alternate host must be distinct from browser host" + ) + + def _get_ws_doc_root(self, data): + if data["ws_doc_root"] is not None: + return data["ws_doc_root"] + else: + return os.path.join(data["doc_root"], "websockets", "handlers") + + def _get_paths(self, data): + rv = super()._get_paths(data) + rv["ws_doc_root"] = data["ws_doc_root"] + return rv + + +def build_config(logger, override_path=None, config_cls=ConfigBuilder, **kwargs): + rv = config_cls(logger) + + enable_http2 = kwargs.get("h2") + if enable_http2 is None: + enable_http2 = True + if enable_http2: + rv._default["ports"]["h2"] = [9000] + + if override_path and os.path.exists(override_path): + with open(override_path) as f: + override_obj = json.load(f) + 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): + with open(other_path) as f: + override_obj = json.load(f) + rv.update(override_obj) + else: + raise ValueError("Config path %s does not exist" % other_path) + + if kwargs.get("verbose"): + rv.log_level = "debug" + + setattr(rv, "inject_script", kwargs.get("inject_script")) + + 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)) + setattr(rv, key, value) + + return rv + + +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") + parser.add_argument("--doc_root", action="store", dest="doc_root", + help="Path to document root. Overrides config.") + parser.add_argument("--ws_doc_root", action="store", dest="ws_doc_root", + help="Path to WebSockets document root. Overrides config.") + parser.add_argument("--inject-script", default=None, + help="Path to script file to inject, useful for testing polyfills.") + parser.add_argument("--alias_file", action="store", dest="alias_file", + help="File with entries for aliases/multiple doc roots. In form of `/ALIAS_NAME/, DOC_ROOT\\n`") + parser.add_argument("--h2", action="store_true", dest="h2", default=None, + help=argparse.SUPPRESS) + parser.add_argument("--no-h2", action="store_false", dest="h2", default=None, + help="Disable the HTTP/2.0 server") + parser.add_argument("--webtransport-h3", action="store_true", + help="Enable WebTransport over HTTP/3 server") + parser.add_argument("--exit-after-start", action="store_true", help="Exit after starting servers") + parser.add_argument("--verbose", action="store_true", help="Enable verbose logging") + parser.set_defaults(report=False) + parser.set_defaults(is_wave=False) + return parser + + +class MpContext: + def __getattr__(self, name): + return getattr(multiprocessing, name) + + +def get_logger(log_level, log_handlers): + """Get a logger configured to log at level log_level + + If the logger has existing handlers the log_handlers argument is ignored. + Otherwise the handlers in log_handlers are added to the logger. If there are + no log_handlers passed and no configured handlers, a stream handler is added + to the logger. + + Typically this is called once per process to set up logging in that process. + + :param log_level: - A string representing a log level e.g. "info" + :param log_handlers: - Optional list of Handler objects. + """ + logger = logging.getLogger() + logger.setLevel(getattr(logging, log_level.upper())) + if not logger.hasHandlers(): + if log_handlers is not None: + for handler in log_handlers: + logger.addHandler(handler) + else: + handler = logging.StreamHandler(sys.stdout) + formatter = logging.Formatter("[%(asctime)s %(processName)s] %(levelname)s - %(message)s") + handler.setFormatter(formatter) + logger.addHandler(handler) + return logger + + +def run(config_cls=ConfigBuilder, route_builder=None, mp_context=None, log_handlers=None, + **kwargs): + logger = get_logger("INFO", log_handlers) + + if mp_context is None: + if hasattr(multiprocessing, "get_context"): + mp_context = multiprocessing.get_context() + else: + mp_context = MpContext() + + with build_config(logger, + os.path.join(repo_root, "config.json"), + config_cls=config_cls, + **kwargs) as config: + # This sets the right log level + logger = get_logger(config.log_level, log_handlers) + + bind_address = config["bind_address"] + + if kwargs.get("alias_file"): + with open(kwargs["alias_file"]) as alias_file: + for line in alias_file: + alias, doc_root = (x.strip() for x in line.split(',')) + config["aliases"].append({ + 'url-path': alias, + 'local-dir': doc_root, + }) + + if route_builder is None: + route_builder = get_route_builder + routes = route_builder(logger, config.aliases, config).get_routes() + + if config["check_subdomains"]: + check_subdomains(logger, config, routes, mp_context, log_handlers) + + stash_address = None + if bind_address: + stash_address = (config.server_host, get_port("")) + logger.debug("Going to use port %d for stash" % stash_address[1]) + + with stash.StashServer(stash_address, authkey=str(uuid.uuid4())): + servers = start(logger, config, routes, mp_context, log_handlers, **kwargs) + + if not kwargs.get("exit_after_start"): + try: + # Periodically check if all the servers are alive + server_process_exited = False + while not server_process_exited: + for server in iter_servers(servers): + server.proc.join(1) + if not server.proc.is_alive(): + server_process_exited = True + break + except KeyboardInterrupt: + pass + + failed_subproc = 0 + for server in iter_servers(servers): + logger.info('Status of subprocess "%s": running', server.proc.name) + server.request_shutdown() + + for server in iter_servers(servers): + server.wait(timeout=1) + if server.proc.exitcode == 0: + logger.info('Status of subprocess "%s": exited correctly', server.proc.name) + else: + subproc = server.proc + logger.warning('Status of subprocess "%s": failed. Exit with non-zero status: %d', + subproc.name, subproc.exitcode) + failed_subproc += 1 + return failed_subproc + + +def main(): + kwargs = vars(get_parser().parse_args()) + return run(**kwargs) diff --git a/testing/web-platform/tests/tools/serve/test_functional.py b/testing/web-platform/tests/tools/serve/test_functional.py new file mode 100644 index 0000000000..94bedb60f7 --- /dev/null +++ b/testing/web-platform/tests/tools/serve/test_functional.py @@ -0,0 +1,81 @@ +# mypy: allow-untyped-defs + +try: + from importlib import reload +except ImportError: + pass +import json +import os +import queue +import tempfile +import threading + +import pytest + +from . import serve +from wptserve import logger + + +class ServerProcSpy(serve.ServerProc): + instances = None + + def start(self, *args, **kwargs): + result = super().start(*args, **kwargs) + + if ServerProcSpy.instances is not None: + ServerProcSpy.instances.put(self) + + return result + + +serve.ServerProc = ServerProcSpy # type: ignore + + +@pytest.fixture() +def server_subprocesses(): + ServerProcSpy.instances = queue.Queue() + yield ServerProcSpy.instances + ServerProcSpy.instances = None + + +@pytest.fixture() +def tempfile_name(): + fd, name = tempfile.mkstemp() + yield name + os.close(fd) + os.remove(name) + + +def test_subprocess_exit(server_subprocesses, tempfile_name): + timeout = 30 + + def target(): + # By default, the server initially creates a child process to validate + # local system configuration. That process is unrelated to the behavior + # under test, but at the time of this writing, the parent uses the same + # constructor that is also used to create the long-running processes + # which are relevant to this functionality. Disable the check so that + # the constructor is only used to create relevant processes. + with open(tempfile_name, 'w') as handle: + json.dump({"check_subdomains": False, "bind_address": False}, handle) + + # The `logger` module from the wptserver package uses a singleton + # pattern which resists testing. In order to avoid conflicting with + # other tests which rely on that module, pre-existing state is + # discarded through an explicit "reload" operation. + reload(logger) + + serve.run(config_path=tempfile_name) + + thread = threading.Thread(target=target) + + thread.start() + + server_subprocesses.get(True, timeout) + subprocess = server_subprocesses.get(True, timeout) + subprocess.request_shutdown() + subprocess.wait() + + thread.join(timeout) + + assert not thread.is_alive() diff --git a/testing/web-platform/tests/tools/serve/test_serve.py b/testing/web-platform/tests/tools/serve/test_serve.py new file mode 100644 index 0000000000..e590b49870 --- /dev/null +++ b/testing/web-platform/tests/tools/serve/test_serve.py @@ -0,0 +1,149 @@ +# mypy: allow-untyped-defs + +import logging +import os +import pickle +import platform + +import pytest + +import localpaths # type: ignore +from . import serve +from .serve import ConfigBuilder, inject_script + + +logger = logging.getLogger() + +@pytest.mark.skipif(platform.uname()[0] == "Windows", + reason="Expected contents are platform-dependent") +def test_make_hosts_file_nix(): + with ConfigBuilder(logger, + ports={"http": [8000]}, + browser_host="foo.bar", + alternate_hosts={"alt": "foo2.bar"}, + subdomains={"a", "b"}, + not_subdomains={"x, y"}) as c: + hosts = serve.make_hosts_file(c, "192.168.42.42") + lines = hosts.split("\n") + assert set(lines) == {"", + "192.168.42.42\tfoo.bar", + "192.168.42.42\tfoo2.bar", + "192.168.42.42\ta.foo.bar", + "192.168.42.42\ta.foo2.bar", + "192.168.42.42\tb.foo.bar", + "192.168.42.42\tb.foo2.bar"} + assert lines[-1] == "" + +@pytest.mark.skipif(platform.uname()[0] != "Windows", + reason="Expected contents are platform-dependent") +def test_make_hosts_file_windows(): + with ConfigBuilder(logger, + ports={"http": [8000]}, + browser_host="foo.bar", + alternate_hosts={"alt": "foo2.bar"}, + subdomains={"a", "b"}, + not_subdomains={"x", "y"}) as c: + hosts = serve.make_hosts_file(c, "192.168.42.42") + lines = hosts.split("\n") + assert set(lines) == {"", + "0.0.0.0\tx.foo.bar", + "0.0.0.0\tx.foo2.bar", + "0.0.0.0\ty.foo.bar", + "0.0.0.0\ty.foo2.bar", + "192.168.42.42\tfoo.bar", + "192.168.42.42\tfoo2.bar", + "192.168.42.42\ta.foo.bar", + "192.168.42.42\ta.foo2.bar", + "192.168.42.42\tb.foo.bar", + "192.168.42.42\tb.foo2.bar"} + assert lines[-1] == "" + + +def test_ws_doc_root_default(): + with ConfigBuilder(logger) as c: + assert c.doc_root == localpaths.repo_root + assert c.ws_doc_root == os.path.join(localpaths.repo_root, "websockets", "handlers") + assert c.paths["ws_doc_root"] == c.ws_doc_root + + +def test_init_ws_doc_root(): + with ConfigBuilder(logger, ws_doc_root="/") as c: + assert c.doc_root == localpaths.repo_root # check this hasn't changed + assert c.ws_doc_root == "/" + assert c.paths["ws_doc_root"] == c.ws_doc_root + + +def test_set_ws_doc_root(): + cb = ConfigBuilder(logger) + cb.ws_doc_root = "/" + with cb as c: + assert c.doc_root == localpaths.repo_root # check this hasn't changed + assert c.ws_doc_root == "/" + assert c.paths["ws_doc_root"] == c.ws_doc_root + + +def test_pickle(): + # Ensure that the config object can be pickled + with ConfigBuilder(logger) as c: + pickle.dumps(c) + + +def test_alternate_host_unspecified(): + ConfigBuilder(logger, browser_host="web-platform.test") + + +@pytest.mark.parametrize("primary, alternate", [ + ("web-platform.test", "web-platform.test"), + ("a.web-platform.test", "web-platform.test"), + ("web-platform.test", "a.web-platform.test"), + ("a.web-platform.test", "a.web-platform.test"), +]) +def test_alternate_host_invalid(primary, alternate): + with pytest.raises(ValueError): + ConfigBuilder(logger, browser_host=primary, alternate_hosts={"alt": alternate}) + +@pytest.mark.parametrize("primary, alternate", [ + ("web-platform.test", "not-web-platform.test"), + ("a.web-platform.test", "b.web-platform.test"), + ("web-platform-tests.dev", "web-platform-tests.live"), +]) +def test_alternate_host_valid(primary, alternate): + ConfigBuilder(logger, browser_host=primary, alternate_hosts={"alt": alternate}) + + +# A token marking the location of expected script injection. +INJECT_SCRIPT_MARKER = b"<!-- inject here -->" + + +def test_inject_script_after_head(): + html = b"""<!DOCTYPE html> + <html> + <head> + <!-- inject here --><script src="test.js"></script> + </head> + <body> + </body> + </html>""" + assert INJECT_SCRIPT_MARKER in html + assert inject_script(html.replace(INJECT_SCRIPT_MARKER, b""), INJECT_SCRIPT_MARKER) == html + + +def test_inject_script_no_html_head(): + html = b"""<!DOCTYPE html> + <!-- inject here --><div></div>""" + assert INJECT_SCRIPT_MARKER in html + assert inject_script(html.replace(INJECT_SCRIPT_MARKER, b""), INJECT_SCRIPT_MARKER) == html + + +def test_inject_script_no_doctype(): + html = b"""<!-- inject here --><div></div>""" + assert INJECT_SCRIPT_MARKER in html + assert inject_script(html.replace(INJECT_SCRIPT_MARKER, b""), INJECT_SCRIPT_MARKER) == html + + +def test_inject_script_parse_error(): + html = b"""<!--<!-- inject here --><div></div>""" + assert INJECT_SCRIPT_MARKER in html + # On a parse error, the script should not be injected and the original content should be + # returned. + assert INJECT_SCRIPT_MARKER not in inject_script(html.replace(INJECT_SCRIPT_MARKER, b""), INJECT_SCRIPT_MARKER) diff --git a/testing/web-platform/tests/tools/serve/wave.py b/testing/web-platform/tests/tools/serve/wave.py new file mode 100644 index 0000000000..b24ec1cc81 --- /dev/null +++ b/testing/web-platform/tests/tools/serve/wave.py @@ -0,0 +1,134 @@ +# The ./wpt serve-wave command is broken, so mypy errors are ignored instead of +# making untestable changes to the problematic imports. +# See https://github.com/web-platform-tests/wpt/issues/29024. +# mypy: ignore-errors + +import subprocess +from manifest import manifest +import localpaths +import os + +try: + from serve import serve +except ImportError: + import serve + +from tools.wpt import wpt + + +class WaveHandler: + def __init__(self, server): + self.server = server + + def __call__(self, request, response): + self.server.handle_request(request, response) + + +def get_route_builder_func(report): + def get_route_builder(logger, aliases, config): + wave_cfg = None + if config is not None and "wave" in config: + wave_cfg = config["wave"] + builder = serve.get_route_builder(logger, aliases, config) + logger.debug("Loading manifest ...") + data = load_manifest() + from ..wave.wave_server import WaveServer + wave_server = WaveServer() + wave_server.initialize( + configuration_file_path=os.path.abspath("./config.json"), + reports_enabled=report, + tests=data["items"]) + + web_root = "wave" + if wave_cfg is not None and "web_root" in wave_cfg: + web_root = wave_cfg["web_root"] + if not web_root.startswith("/"): + web_root = "/" + web_root + + wave_handler = WaveHandler(wave_server) + builder.add_handler("*", web_root + "*", wave_handler) + # serving wave specifc testharnessreport.js + file_path = os.path.join(wpt.localpaths.repo_root, "tools/wave/resources/testharnessreport.js") + builder.add_static( + file_path, + {}, + "text/javascript;charset=utf8", + "/resources/testharnessreport.js") + + return builder + return get_route_builder + + +class ConfigBuilder(serve.ConfigBuilder): + _default = serve.ConfigBuilder._default + _default.update({ + "wave": { # wave specific configuration parameters + "results": "./results", + "timeouts": { + "automatic": 60000, + "manual": 300000 + }, + "enable_results_import": False, + "web_root": "/_wave", + "persisting_interval": 20, + "api_titles": [] + } + }) + + +def get_parser(): + parser = serve.get_parser() + # Added wave specific arguments + parser.add_argument("--report", action="store_true", dest="report", + help="Flag for enabling the WPTReporting server") + return parser + + +def run(venv=None, **kwargs): + if venv is not None: + venv.start() + else: + raise Exception("Missing virtualenv for serve-wave.") + + if kwargs['report'] is True: + if not is_wptreport_installed(): + raise Exception("wptreport is not installed. Please install it from https://github.com/w3c/wptreport") + + serve.run(config_cls=ConfigBuilder, + route_builder=get_route_builder_func(kwargs["report"]), + log_handlers=None, + **kwargs) + + +# execute wptreport version check +def is_wptreport_installed(): + try: + subprocess.check_output(["wptreport", "--help"]) + return True + except Exception: + return False + + +def load_manifest(): + root = localpaths.repo_root + path = os.path.join(root, "MANIFEST.json") + manifest_file = manifest.load_and_update(root, path, "/", parallel=False) + + supported_types = ["testharness", "manual"] + data = {"items": {}, + "url_base": "/"} + for item_type in supported_types: + data["items"][item_type] = {} + for item_type, path, tests in manifest_file.itertypes(*supported_types): + tests_data = [] + for item in tests: + test_data = [item.url[1:]] + if item_type == "reftest": + test_data.append(item.references) + test_data.append({}) + if item_type != "manual": + test_data[-1]["timeout"] = item.timeout + tests_data.append(test_data) + assert path not in data["items"][item_type] + data["items"][item_type][path] = tests_data + return data |