diff options
Diffstat (limited to '')
-rw-r--r-- | testing/mochitest/runtests.py | 4102 |
1 files changed, 4102 insertions, 0 deletions
diff --git a/testing/mochitest/runtests.py b/testing/mochitest/runtests.py new file mode 100644 index 0000000000..6605b4ce20 --- /dev/null +++ b/testing/mochitest/runtests.py @@ -0,0 +1,4102 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +""" +Runs the Mochitest test harness. +""" + +import os +import sys + +SCRIPT_DIR = os.path.abspath(os.path.realpath(os.path.dirname(__file__))) +sys.path.insert(0, SCRIPT_DIR) + +import ctypes +import glob +import json +import numbers +import platform +import re +import shlex +import shutil +import signal +import socket +import subprocess +import sys +import tempfile +import time +import traceback +import uuid +import zipfile +from argparse import Namespace +from collections import defaultdict +from contextlib import closing +from ctypes.util import find_library +from datetime import datetime, timedelta +from distutils import spawn + +import bisection +import mozcrash +import mozdebug +import mozinfo +import mozprocess +import mozrunner +from manifestparser import TestManifest +from manifestparser.filters import ( + chunk_by_dir, + chunk_by_runtime, + chunk_by_slice, + failures, + pathprefix, + subsuite, + tags, +) +from manifestparser.util import normsep +from mozgeckoprofiler import symbolicate_profile_json, view_gecko_profile +from mozserve import DoHServer, Http3Server + +try: + from marionette_driver.addons import Addons + from marionette_driver.marionette import Marionette +except ImportError as e: # noqa + # Defer ImportError until attempt to use Marionette + def reraise(*args, **kwargs): + raise (e) # noqa + + Marionette = reraise + +import mozleak +from leaks import LSANLeaks, ShutdownLeaks +from mochitest_options import ( + MochitestArgumentParser, + build_obj, + get_default_valgrind_suppression_files, +) +from mozlog import commandline, get_proxy_logger +from mozprofile import Profile +from mozprofile.cli import KeyValueParseError, parse_key_value, parse_preferences +from mozprofile.permissions import ServerLocations +from mozrunner.utils import get_stack_fixer_function, test_environment +from mozscreenshot import dump_screen + +HAVE_PSUTIL = False +try: + import psutil + + HAVE_PSUTIL = True +except ImportError: + pass + +import six +from six.moves.urllib.parse import quote_plus as encodeURIComponent +from six.moves.urllib_request import urlopen + +try: + from mozbuild.base import MozbuildObject + + build = MozbuildObject.from_environment(cwd=SCRIPT_DIR) +except ImportError: + build = None + +here = os.path.abspath(os.path.dirname(__file__)) + +NO_TESTS_FOUND = """ +No tests were found for flavor '{}' and the following manifest filters: +{} + +Make sure the test paths (if any) are spelt correctly and the corresponding +--flavor and --subsuite are being used. See `mach mochitest --help` for a +list of valid flavors. +""".lstrip() + + +######################################## +# Option for MOZ (former NSPR) logging # +######################################## + +# Set the desired log modules you want a log be produced +# by a try run for, or leave blank to disable the feature. +# This will be passed to MOZ_LOG environment variable. +# Try run will then put a download link for a zip archive +# of all the log files on treeherder. +MOZ_LOG = "" + +######################################## +# Option for web server log # +######################################## + +# If True, debug logging from the web server will be +# written to mochitest-server-%d.txt artifacts on +# treeherder. +MOCHITEST_SERVER_LOGGING = False + +##################### +# Test log handling # +##################### + +# output processing +TBPL_RETRY = 4 # Defined in mozharness + + +class MessageLogger(object): + + """File-like object for logging messages (structured logs)""" + + BUFFERING_THRESHOLD = 100 + # This is a delimiter used by the JS side to avoid logs interleaving + DELIMITER = "\ue175\uee31\u2c32\uacbf" + BUFFERED_ACTIONS = set(["test_status", "log"]) + VALID_ACTIONS = set( + [ + "suite_start", + "suite_end", + "test_start", + "test_end", + "test_status", + "log", + "assertion_count", + "buffering_on", + "buffering_off", + ] + ) + # Regexes that will be replaced with an empty string if found in a test + # name. We do this to normalize test names which may contain URLs and test + # package prefixes. + TEST_PATH_PREFIXES = [ + r"^/tests/", + r"^\w+://[\w\.]+(:\d+)?(/\w+)?/(tests?|a11y|chrome|browser)/", + ] + + def __init__(self, logger, buffering=True, structured=True): + self.logger = logger + self.structured = structured + self.gecko_id = "GECKO" + self.is_test_running = False + + # Even if buffering is enabled, we only want to buffer messages between + # TEST-START/TEST-END. So it is off to begin, but will be enabled after + # a TEST-START comes in. + self._buffering = False + self.restore_buffering = buffering + + # Guard to ensure we never buffer if this value was initially `False` + self._buffering_initially_enabled = buffering + + # Message buffering + self.buffered_messages = [] + + def validate(self, obj): + """Tests whether the given object is a valid structured message + (only does a superficial validation)""" + if not ( + isinstance(obj, dict) + and "action" in obj + and obj["action"] in MessageLogger.VALID_ACTIONS + ): + raise ValueError + + def _fix_subtest_name(self, message): + """Make sure subtest name is a string""" + if "subtest" in message and not isinstance( + message["subtest"], six.string_types + ): + message["subtest"] = str(message["subtest"]) + + def _fix_test_name(self, message): + """Normalize a logged test path to match the relative path from the sourcedir.""" + if message.get("test") is not None: + test = message["test"] + for pattern in MessageLogger.TEST_PATH_PREFIXES: + test = re.sub(pattern, "", test) + if test != message["test"]: + message["test"] = test + break + + def _fix_message_format(self, message): + if "message" in message: + if isinstance(message["message"], bytes): + message["message"] = message["message"].decode("utf-8", "replace") + elif not isinstance(message["message"], six.text_type): + message["message"] = six.text_type(message["message"]) + + def parse_line(self, line): + """Takes a given line of input (structured or not) and + returns a list of structured messages""" + if isinstance(line, six.binary_type): + # if line is a sequence of bytes, let's decode it + line = line.rstrip().decode("UTF-8", "replace") + else: + # line is in unicode - so let's use it as it is + line = line.rstrip() + + messages = [] + for fragment in line.split(MessageLogger.DELIMITER): + if not fragment: + continue + try: + message = json.loads(fragment) + self.validate(message) + except ValueError: + if self.structured: + message = dict( + action="process_output", + process=self.gecko_id, + data=fragment, + ) + else: + message = dict( + action="log", + level="info", + message=fragment, + ) + + self._fix_subtest_name(message) + self._fix_test_name(message) + self._fix_message_format(message) + messages.append(message) + + return messages + + @property + def buffering(self): + if not self._buffering_initially_enabled: + return False + return self._buffering + + @buffering.setter + def buffering(self, val): + self._buffering = val + + def process_message(self, message): + """Processes a structured message. Takes into account buffering, errors, ...""" + # Activation/deactivating message buffering from the JS side + if message["action"] == "buffering_on": + if self.is_test_running: + self.buffering = True + return + if message["action"] == "buffering_off": + self.buffering = False + return + + # Error detection also supports "raw" errors (in log messages) because some tests + # manually dump 'TEST-UNEXPECTED-FAIL'. + if "expected" in message or ( + message["action"] == "log" + and message.get("message", "").startswith("TEST-UNEXPECTED") + ): + self.restore_buffering = self.restore_buffering or self.buffering + self.buffering = False + if self.buffered_messages: + snipped = len(self.buffered_messages) - self.BUFFERING_THRESHOLD + if snipped > 0: + self.logger.info( + "<snipped {0} output lines - " + "if you need more context, please use " + "SimpleTest.requestCompleteLog() in your test>".format(snipped) + ) + # Dumping previously buffered messages + self.dump_buffered(limit=True) + + # Logging the error message + self.logger.log_raw(message) + # Determine if message should be buffered + elif ( + self.buffering + and self.structured + and message["action"] in self.BUFFERED_ACTIONS + ): + self.buffered_messages.append(message) + # Otherwise log the message directly + else: + self.logger.log_raw(message) + + # If a test ended, we clean the buffer + if message["action"] == "test_end": + self.is_test_running = False + self.buffered_messages = [] + self.restore_buffering = self.restore_buffering or self.buffering + self.buffering = False + + if message["action"] == "test_start": + self.is_test_running = True + if self.restore_buffering: + self.restore_buffering = False + self.buffering = True + + def write(self, line): + messages = self.parse_line(line) + for message in messages: + self.process_message(message) + return messages + + def flush(self): + sys.stdout.flush() + + def dump_buffered(self, limit=False): + if limit: + dumped_messages = self.buffered_messages[-self.BUFFERING_THRESHOLD :] + else: + dumped_messages = self.buffered_messages + + last_timestamp = None + for buf in dumped_messages: + # pylint --py3k W1619 + timestamp = datetime.fromtimestamp(buf["time"] / 1000).strftime("%H:%M:%S") + if timestamp != last_timestamp: + self.logger.info("Buffered messages logged at {}".format(timestamp)) + last_timestamp = timestamp + + self.logger.log_raw(buf) + self.logger.info("Buffered messages finished") + # Cleaning the list of buffered messages + self.buffered_messages = [] + + def finish(self): + self.dump_buffered() + self.buffering = False + self.logger.suite_end() + + +#################### +# PROCESS HANDLING # +#################### + + +def call(*args, **kwargs): + """front-end function to mozprocess.ProcessHandler""" + # TODO: upstream -> mozprocess + # https://bugzilla.mozilla.org/show_bug.cgi?id=791383 + log = get_proxy_logger("mochitest") + + def on_output(line): + log.process_output( + process=process.pid, + data=line.decode("utf8", "replace"), + command=process.commandline, + ) + + process = mozprocess.ProcessHandlerMixin( + *args, processOutputLine=on_output, **kwargs + ) + process.run() + return process.wait() + + +def killPid(pid, log): + # see also https://bugzilla.mozilla.org/show_bug.cgi?id=911249#c58 + + if HAVE_PSUTIL: + # Kill a process tree (including grandchildren) with signal.SIGTERM + if pid == os.getpid(): + raise RuntimeError("Error: trying to kill ourselves, not another process") + try: + parent = psutil.Process(pid) + children = parent.children(recursive=True) + children.append(parent) + for p in children: + p.send_signal(signal.SIGTERM) + gone, alive = psutil.wait_procs(children, timeout=30) + for p in gone: + log.info("psutil found pid %s dead" % p.pid) + for p in alive: + log.info("failed to kill pid %d after 30s" % p.pid) + except Exception as e: + log.info("Error: Failed to kill process %d: %s" % (pid, str(e))) + else: + try: + os.kill(pid, getattr(signal, "SIGKILL", signal.SIGTERM)) + except Exception as e: + log.info("Failed to kill process %d: %s" % (pid, str(e))) + + +if mozinfo.isWin: + import ctypes.wintypes + + def isPidAlive(pid): + STILL_ACTIVE = 259 + PROCESS_QUERY_LIMITED_INFORMATION = 0x1000 + pHandle = ctypes.windll.kernel32.OpenProcess( + PROCESS_QUERY_LIMITED_INFORMATION, 0, pid + ) + if not pHandle: + return False + + try: + pExitCode = ctypes.wintypes.DWORD() + ctypes.windll.kernel32.GetExitCodeProcess(pHandle, ctypes.byref(pExitCode)) + + if pExitCode.value != STILL_ACTIVE: + return False + + # We have a live process handle. But Windows aggressively + # re-uses pids, so let's attempt to verify that this is + # actually Firefox. + namesize = 1024 + pName = ctypes.create_string_buffer(namesize) + namelen = ctypes.windll.psapi.GetProcessImageFileNameA( + pHandle, pName, namesize + ) + if namelen == 0: + # Still an active process, so conservatively assume it's Firefox. + return True + + return pName.value.endswith((b"firefox.exe", b"plugin-container.exe")) + finally: + ctypes.windll.kernel32.CloseHandle(pHandle) + + +else: + import errno + + def isPidAlive(pid): + try: + # kill(pid, 0) checks for a valid PID without actually sending a signal + # The method throws OSError if the PID is invalid, which we catch + # below. + os.kill(pid, 0) + + # Wait on it to see if it's a zombie. This can throw OSError.ECHILD if + # the process terminates before we get to this point. + wpid, wstatus = os.waitpid(pid, os.WNOHANG) + return wpid == 0 + except OSError as err: + # Catch the errors we might expect from os.kill/os.waitpid, + # and re-raise any others + if err.errno in (errno.ESRCH, errno.ECHILD, errno.EPERM): + return False + raise + + +# TODO: ^ upstream isPidAlive to mozprocess + +####################### +# HTTP SERVER SUPPORT # +####################### + + +class MochitestServer(object): + + "Web server used to serve Mochitests, for closer fidelity to the real web." + + instance_count = 0 + + def __init__(self, options, logger): + if isinstance(options, Namespace): + options = vars(options) + self._log = logger + self._keep_open = bool(options["keep_open"]) + self._utilityPath = options["utilityPath"] + self._xrePath = options["xrePath"] + self._profileDir = options["profilePath"] + self.webServer = options["webServer"] + self.httpPort = options["httpPort"] + if options.get("remoteWebServer") == "10.0.2.2": + # probably running an Android emulator and 10.0.2.2 will + # not be visible from host + shutdownServer = "127.0.0.1" + else: + shutdownServer = self.webServer + self.shutdownURL = "http://%(server)s:%(port)s/server/shutdown" % { + "server": shutdownServer, + "port": self.httpPort, + } + self.debugURL = "http://%(server)s:%(port)s/server/debug?2" % { + "server": shutdownServer, + "port": self.httpPort, + } + self.testPrefix = "undefined" + + if options.get("httpdPath"): + self._httpdPath = options["httpdPath"] + else: + self._httpdPath = SCRIPT_DIR + self._httpdPath = os.path.abspath(self._httpdPath) + + MochitestServer.instance_count += 1 + + def start(self): + "Run the Mochitest server, returning the process ID of the server." + + # get testing environment + env = test_environment(xrePath=self._xrePath, log=self._log) + env["XPCOM_DEBUG_BREAK"] = "warn" + if "LD_LIBRARY_PATH" not in env or env["LD_LIBRARY_PATH"] is None: + env["LD_LIBRARY_PATH"] = self._xrePath + else: + env["LD_LIBRARY_PATH"] = ":".join([self._xrePath, env["LD_LIBRARY_PATH"]]) + + # When running with an ASan build, our xpcshell server will also be ASan-enabled, + # thus consuming too much resources when running together with the browser on + # the test machines. Try to limit the amount of resources by disabling certain + # features. + env["ASAN_OPTIONS"] = "quarantine_size=1:redzone=32:malloc_context_size=5" + + # Likewise, when running with a TSan build, our xpcshell server will + # also be TSan-enabled. Except that in this case, we don't really + # care about races in xpcshell. So disable TSan for the server. + env["TSAN_OPTIONS"] = "report_bugs=0" + + # Don't use socket process for the xpcshell server. + env["MOZ_DISABLE_SOCKET_PROCESS"] = "1" + + if mozinfo.isWin: + env["PATH"] = env["PATH"] + ";" + str(self._xrePath) + + args = [ + "-g", + self._xrePath, + "-f", + os.path.join(self._httpdPath, "httpd.js"), + "-e", + "const _PROFILE_PATH = '%(profile)s'; const _SERVER_PORT = '%(port)s'; " + "const _SERVER_ADDR = '%(server)s'; const _TEST_PREFIX = %(testPrefix)s; " + "const _DISPLAY_RESULTS = %(displayResults)s;" + % { + "profile": self._profileDir.replace("\\", "\\\\"), + "port": self.httpPort, + "server": self.webServer, + "testPrefix": self.testPrefix, + "displayResults": str(self._keep_open).lower(), + }, + "-f", + os.path.join(SCRIPT_DIR, "server.js"), + ] + + xpcshell = os.path.join( + self._utilityPath, "xpcshell" + mozinfo.info["bin_suffix"] + ) + command = [xpcshell] + args + server_logfile = None + if MOCHITEST_SERVER_LOGGING and "MOZ_UPLOAD_DIR" in os.environ: + server_logfile = os.path.join( + os.environ["MOZ_UPLOAD_DIR"], + "mochitest-server-%d.txt" % MochitestServer.instance_count, + ) + self._process = mozprocess.ProcessHandler( + command, cwd=SCRIPT_DIR, env=env, logfile=server_logfile + ) + self._process.run() + self._log.info("%s : launching %s" % (self.__class__.__name__, command)) + pid = self._process.pid + self._log.info("runtests.py | Server pid: %d" % pid) + if MOCHITEST_SERVER_LOGGING and "MOZ_UPLOAD_DIR" in os.environ: + self._log.info("runtests.py enabling server debugging...") + i = 0 + while i < 5: + try: + with closing(urlopen(self.debugURL)) as c: + self._log.info(six.ensure_text(c.read())) + break + except Exception as e: + self._log.info("exception when enabling debugging: %s" % str(e)) + time.sleep(1) + i += 1 + + def ensureReady(self, timeout): + assert timeout >= 0 + + aliveFile = os.path.join(self._profileDir, "server_alive.txt") + i = 0 + while i < timeout: + if os.path.exists(aliveFile): + break + time.sleep(0.05) + i += 0.05 + else: + self._log.error( + "TEST-UNEXPECTED-FAIL | runtests.py | Timed out while waiting for server startup." + ) + self.stop() + sys.exit(1) + + def stop(self): + try: + with closing(urlopen(self.shutdownURL)) as c: + self._log.info(six.ensure_text(c.read())) + except Exception: + self._log.info("Failed to stop web server on %s" % self.shutdownURL) + traceback.print_exc() + finally: + if self._process is not None: + # Kill the server immediately to avoid logging intermittent + # shutdown crashes, sometimes observed on Windows 10. + self._process.kill() + self._log.info("Web server killed.") + + +class WebSocketServer(object): + + "Class which encapsulates the mod_pywebsocket server" + + def __init__(self, options, scriptdir, logger, debuggerInfo=None): + self.port = options.webSocketPort + self.debuggerInfo = debuggerInfo + self._log = logger + self._scriptdir = scriptdir + + def start(self): + # Invoke pywebsocket through a wrapper which adds special SIGINT handling. + # + # If we're in an interactive debugger, the wrapper causes the server to + # ignore SIGINT so the server doesn't capture a ctrl+c meant for the + # debugger. + # + # If we're not in an interactive debugger, the wrapper causes the server to + # die silently upon receiving a SIGINT. + scriptPath = "pywebsocket_wrapper.py" + script = os.path.join(self._scriptdir, scriptPath) + + cmd = [sys.executable, script] + if self.debuggerInfo and self.debuggerInfo.interactive: + cmd += ["--interactive"] + cmd += [ + "-H", + "127.0.0.1", + "-p", + str(self.port), + "-w", + self._scriptdir, + "-l", + os.path.join(self._scriptdir, "websock.log"), + "--log-level=debug", + "--allow-handlers-outside-root-dir", + ] + env = dict(os.environ) + env["PYTHONPATH"] = os.pathsep.join(sys.path) + # Start the process. Ignore stderr so that exceptions from the server + # are not treated as failures when parsing the test log. + self._process = mozprocess.ProcessHandler( + cmd, + cwd=SCRIPT_DIR, + env=env, + processStderrLine=lambda _: None, + ) + self._process.run() + pid = self._process.pid + self._log.info("runtests.py | Websocket server pid: %d" % pid) + + def stop(self): + if self._process is not None: + self._process.kill() + + +class SSLTunnel: + def __init__(self, options, logger): + self.log = logger + self.process = None + self.utilityPath = options.utilityPath + self.xrePath = options.xrePath + self.certPath = options.certPath + self.sslPort = options.sslPort + self.httpPort = options.httpPort + self.webServer = options.webServer + self.webSocketPort = options.webSocketPort + + self.customCertRE = re.compile("^cert=(?P<nickname>[0-9a-zA-Z_ ]+)") + self.clientAuthRE = re.compile("^clientauth=(?P<clientauth>[a-z]+)") + self.redirRE = re.compile("^redir=(?P<redirhost>[0-9a-zA-Z_ .]+)") + + def writeLocation(self, config, loc): + for option in loc.options: + match = self.customCertRE.match(option) + if match: + customcert = match.group("nickname") + config.write( + "listen:%s:%s:%s:%s\n" + % (loc.host, loc.port, self.sslPort, customcert) + ) + + match = self.clientAuthRE.match(option) + if match: + clientauth = match.group("clientauth") + config.write( + "clientauth:%s:%s:%s:%s\n" + % (loc.host, loc.port, self.sslPort, clientauth) + ) + + match = self.redirRE.match(option) + if match: + redirhost = match.group("redirhost") + config.write( + "redirhost:%s:%s:%s:%s\n" + % (loc.host, loc.port, self.sslPort, redirhost) + ) + + if option in ( + "tls1", + "tls1_1", + "tls1_2", + "tls1_3", + "ssl3", + "3des", + "failHandshake", + ): + config.write( + "%s:%s:%s:%s\n" % (option, loc.host, loc.port, self.sslPort) + ) + + def buildConfig(self, locations, public=None): + """Create the ssltunnel configuration file""" + configFd, self.configFile = tempfile.mkstemp(prefix="ssltunnel", suffix=".cfg") + with os.fdopen(configFd, "w") as config: + config.write("httpproxy:1\n") + config.write("certdbdir:%s\n" % self.certPath) + config.write("forward:127.0.0.1:%s\n" % self.httpPort) + config.write( + "websocketserver:%s:%s\n" % (self.webServer, self.webSocketPort) + ) + # Use "*" to tell ssltunnel to listen on the public ip + # address instead of the loopback address 127.0.0.1. This + # may have the side-effect of causing firewall warnings on + # macOS and Windows. Use "127.0.0.1" to listen on the + # loopback address. Remote tests using physical or + # emulated Android devices must use the public ip address + # in order for the sslproxy to work but Desktop tests + # which run on the same host as ssltunnel may use the + # loopback address. + listen_address = "*" if public else "127.0.0.1" + config.write("listen:%s:%s:pgoserver\n" % (listen_address, self.sslPort)) + + for loc in locations: + if loc.scheme == "https" and "nocert" not in loc.options: + self.writeLocation(config, loc) + + def start(self): + """Starts the SSL Tunnel""" + + # start ssltunnel to provide https:// URLs capability + ssltunnel = os.path.join(self.utilityPath, "ssltunnel") + if os.name == "nt": + ssltunnel += ".exe" + if not os.path.exists(ssltunnel): + self.log.error( + "INFO | runtests.py | expected to find ssltunnel at %s" % ssltunnel + ) + exit(1) + + env = test_environment(xrePath=self.xrePath, log=self.log) + env["LD_LIBRARY_PATH"] = self.xrePath + self.process = mozprocess.ProcessHandler([ssltunnel, self.configFile], env=env) + self.process.run() + self.log.info("runtests.py | SSL tunnel pid: %d" % self.process.pid) + + def stop(self): + """Stops the SSL Tunnel and cleans up""" + if self.process is not None: + self.process.kill() + if os.path.exists(self.configFile): + os.remove(self.configFile) + + +def checkAndConfigureV4l2loopback(device): + """ + Determine if a given device path is a v4l2loopback device, and if so + toggle a few settings on it via fcntl. Very linux-specific. + + Returns (status, device name) where status is a boolean. + """ + if not mozinfo.isLinux: + return False, "" + + libc = ctypes.cdll.LoadLibrary(find_library("c")) + O_RDWR = 2 + # These are from linux/videodev2.h + + class v4l2_capability(ctypes.Structure): + _fields_ = [ + ("driver", ctypes.c_char * 16), + ("card", ctypes.c_char * 32), + ("bus_info", ctypes.c_char * 32), + ("version", ctypes.c_uint32), + ("capabilities", ctypes.c_uint32), + ("device_caps", ctypes.c_uint32), + ("reserved", ctypes.c_uint32 * 3), + ] + + VIDIOC_QUERYCAP = 0x80685600 + + fd = libc.open(six.ensure_binary(device), O_RDWR) + if fd < 0: + return False, "" + + vcap = v4l2_capability() + if libc.ioctl(fd, VIDIOC_QUERYCAP, ctypes.byref(vcap)) != 0: + return False, "" + + if six.ensure_text(vcap.driver) != "v4l2 loopback": + return False, "" + + class v4l2_control(ctypes.Structure): + _fields_ = [("id", ctypes.c_uint32), ("value", ctypes.c_int32)] + + # These are private v4l2 control IDs, see: + # https://github.com/umlaeute/v4l2loopback/blob/fd822cf0faaccdf5f548cddd9a5a3dcebb6d584d/v4l2loopback.c#L131 + KEEP_FORMAT = 0x8000000 + SUSTAIN_FRAMERATE = 0x8000001 + VIDIOC_S_CTRL = 0xC008561C + + control = v4l2_control() + control.id = KEEP_FORMAT + control.value = 1 + libc.ioctl(fd, VIDIOC_S_CTRL, ctypes.byref(control)) + + control.id = SUSTAIN_FRAMERATE + control.value = 1 + libc.ioctl(fd, VIDIOC_S_CTRL, ctypes.byref(control)) + libc.close(fd) + + return True, six.ensure_text(vcap.card) + + +def findTestMediaDevices(log): + """ + Find the test media devices configured on this system, and return a dict + containing information about them. The dict will have keys for 'audio' + and 'video', each containing the name of the media device to use. + + If audio and video devices could not be found, return None. + + This method is only currently implemented for Linux. + """ + if not mozinfo.isLinux: + return None + + info = {} + # Look for a v4l2loopback device. + name = None + device = None + for dev in sorted(glob.glob("/dev/video*")): + result, name_ = checkAndConfigureV4l2loopback(dev) + if result: + name = name_ + device = dev + break + + if not (name and device): + log.error("Couldn't find a v4l2loopback video device") + return None + + # Feed it a frame of output so it has something to display + gst01 = spawn.find_executable("gst-launch-0.1") + gst010 = spawn.find_executable("gst-launch-0.10") + gst10 = spawn.find_executable("gst-launch-1.0") + if gst01: + gst = gst01 + if gst010: + gst = gst010 + else: + gst = gst10 + process = mozprocess.ProcessHandler( + [ + gst, + "--no-fault", + "videotestsrc", + "pattern=green", + "num-buffers=1", + "!", + "v4l2sink", + "device=%s" % device, + ] + ) + process.run() + info["video"] = {"name": name, "process": process} + + # check if PulseAudio module-null-sink is loaded + pactl = spawn.find_executable("pactl") + + if not pactl: + log.error("Could not find pactl on system") + return None + + try: + o = subprocess.check_output([pactl, "list", "short", "modules"]) + except subprocess.CalledProcessError: + log.error("Could not list currently loaded modules") + return None + + null_sink = [x for x in o.splitlines() if b"module-null-sink" in x] + + if not null_sink: + try: + subprocess.check_call([pactl, "load-module", "module-null-sink"]) + except subprocess.CalledProcessError: + log.error("Could not load module-null-sink") + return None + + # Hardcode the name since it's always the same. + info["audio"] = {"name": "Monitor of Null Output"} + return info + + +def create_zip(path): + """ + Takes a `path` on disk and creates a zipfile with its contents. Returns a + path to the location of the temporary zip file. + """ + with tempfile.NamedTemporaryFile() as f: + # `shutil.make_archive` writes to "{f.name}.zip", so we're really just + # using `NamedTemporaryFile` as a way to get a random path. + return shutil.make_archive(f.name, "zip", path) + + +def update_mozinfo(): + """walk up directories to find mozinfo.json update the info""" + # TODO: This should go in a more generic place, e.g. mozinfo + + path = SCRIPT_DIR + dirs = set() + while path != os.path.expanduser("~"): + if path in dirs: + break + dirs.add(path) + path = os.path.split(path)[0] + + mozinfo.find_and_update_from_json(*dirs) + + +class MochitestDesktop(object): + """ + Mochitest class for desktop firefox. + """ + + oldcwd = os.getcwd() + + # Path to the test script on the server + TEST_PATH = "tests" + CHROME_PATH = "redirect.html" + + certdbNew = False + sslTunnel = None + DEFAULT_TIMEOUT = 60.0 + mediaDevices = None + mozinfo_variables_shown = False + + patternFiles = {} + + # XXX use automation.py for test name to avoid breaking legacy + # TODO: replace this with 'runtests.py' or 'mochitest' or the like + test_name = "automation.py" + + def __init__(self, flavor, logger_options, staged_addons=None, quiet=False): + update_mozinfo() + self.flavor = flavor + self.staged_addons = staged_addons + self.server = None + self.wsserver = None + self.websocketProcessBridge = None + self.sslTunnel = None + self.manifest = None + self.tests_by_manifest = defaultdict(list) + self.args_by_manifest = defaultdict(set) + self.prefs_by_manifest = defaultdict(set) + self.env_vars_by_manifest = defaultdict(set) + self.tests_dirs_by_manifest = defaultdict(set) + self._active_tests = None + self.currentTests = None + self._locations = None + self.browserEnv = None + + self.marionette = None + self.start_script = None + self.mozLogs = None + self.start_script_kwargs = {} + self.extraArgs = [] + self.extraPrefs = {} + self.extraEnv = {} + self.extraTestsDirs = [] + self.conditioned_profile_dir = None + + if logger_options.get("log"): + self.log = logger_options["log"] + else: + self.log = commandline.setup_logging( + "mochitest", logger_options, {"tbpl": sys.stdout} + ) + + self.message_logger = MessageLogger( + logger=self.log, buffering=quiet, structured=True + ) + + # Max time in seconds to wait for server startup before tests will fail -- if + # this seems big, it's mostly for debug machines where cold startup + # (particularly after a build) takes forever. + self.SERVER_STARTUP_TIMEOUT = 180 if mozinfo.info.get("debug") else 90 + + # metro browser sub process id + self.browserProcessId = None + + self.haveDumpedScreen = False + # Create variables to count the number of passes, fails, todos. + self.countpass = 0 + self.countfail = 0 + self.counttodo = 0 + + self.expectedError = {} + self.result = {} + + self.start_script = os.path.join(here, "start_desktop.js") + + # Used to temporarily serve a performance profile + self.profiler_tempdir = None + + def environment(self, **kwargs): + kwargs["log"] = self.log + return test_environment(**kwargs) + + def getFullPath(self, path): + "Get an absolute path relative to self.oldcwd." + return os.path.normpath(os.path.join(self.oldcwd, os.path.expanduser(path))) + + def getLogFilePath(self, logFile): + """return the log file path relative to the device we are testing on, in most cases + it will be the full path on the local system + """ + return self.getFullPath(logFile) + + @property + def locations(self): + if self._locations is not None: + return self._locations + locations_file = os.path.join(SCRIPT_DIR, "server-locations.txt") + self._locations = ServerLocations(locations_file) + return self._locations + + def buildURLOptions(self, options, env): + """Add test control options from the command line to the url + + URL parameters to test URL: + + autorun -- kick off tests automatically + closeWhenDone -- closes the browser after the tests + hideResultsTable -- hides the table of individual test results + logFile -- logs test run to an absolute path + startAt -- name of test to start at + endAt -- name of test to end at + timeout -- per-test timeout in seconds + repeat -- How many times to repeat the test, ie: repeat=1 will run the test twice. + """ + self.urlOpts = [] + + if not hasattr(options, "logFile"): + options.logFile = "" + if not hasattr(options, "fileLevel"): + options.fileLevel = "INFO" + + # allow relative paths for logFile + if options.logFile: + options.logFile = self.getLogFilePath(options.logFile) + + if options.flavor in ("a11y", "browser", "chrome"): + self.makeTestConfig(options) + else: + if options.autorun: + self.urlOpts.append("autorun=1") + if options.timeout: + self.urlOpts.append("timeout=%d" % options.timeout) + if options.maxTimeouts: + self.urlOpts.append("maxTimeouts=%d" % options.maxTimeouts) + if not options.keep_open: + self.urlOpts.append("closeWhenDone=1") + if options.logFile: + self.urlOpts.append("logFile=" + encodeURIComponent(options.logFile)) + self.urlOpts.append( + "fileLevel=" + encodeURIComponent(options.fileLevel) + ) + if options.consoleLevel: + self.urlOpts.append( + "consoleLevel=" + encodeURIComponent(options.consoleLevel) + ) + if options.startAt: + self.urlOpts.append("startAt=%s" % options.startAt) + if options.endAt: + self.urlOpts.append("endAt=%s" % options.endAt) + if options.shuffle: + self.urlOpts.append("shuffle=1") + if "MOZ_HIDE_RESULTS_TABLE" in env and env["MOZ_HIDE_RESULTS_TABLE"] == "1": + self.urlOpts.append("hideResultsTable=1") + if options.runUntilFailure: + self.urlOpts.append("runUntilFailure=1") + if options.repeat: + self.urlOpts.append("repeat=%d" % options.repeat) + if len(options.test_paths) == 1 and os.path.isfile( + os.path.join( + self.oldcwd, + os.path.dirname(__file__), + self.TEST_PATH, + options.test_paths[0], + ) + ): + self.urlOpts.append( + "testname=%s" % "/".join([self.TEST_PATH, options.test_paths[0]]) + ) + if options.manifestFile: + self.urlOpts.append("manifestFile=%s" % options.manifestFile) + if options.failureFile: + self.urlOpts.append( + "failureFile=%s" % self.getFullPath(options.failureFile) + ) + if options.runSlower: + self.urlOpts.append("runSlower=true") + if options.debugOnFailure: + self.urlOpts.append("debugOnFailure=true") + if options.dumpOutputDirectory: + self.urlOpts.append( + "dumpOutputDirectory=%s" + % encodeURIComponent(options.dumpOutputDirectory) + ) + if options.dumpAboutMemoryAfterTest: + self.urlOpts.append("dumpAboutMemoryAfterTest=true") + if options.dumpDMDAfterTest: + self.urlOpts.append("dumpDMDAfterTest=true") + if options.debugger: + self.urlOpts.append("interactiveDebugger=true") + if options.jscov_dir_prefix: + self.urlOpts.append("jscovDirPrefix=%s" % options.jscov_dir_prefix) + if options.cleanupCrashes: + self.urlOpts.append("cleanupCrashes=true") + if "MOZ_XORIGIN_MOCHITEST" in env and env["MOZ_XORIGIN_MOCHITEST"] == "1": + options.xOriginTests = True + if options.xOriginTests: + self.urlOpts.append("xOriginTests=true") + if options.comparePrefs: + self.urlOpts.append("comparePrefs=true") + self.urlOpts.append("ignorePrefsFile=ignorePrefs.json") + + def normflavor(self, flavor): + """ + In some places the string 'browser-chrome' is expected instead of + 'browser' and 'mochitest' instead of 'plain'. Normalize the flavor + strings for those instances. + """ + # TODO Use consistent flavor strings everywhere and remove this + if flavor == "browser": + return "browser-chrome" + elif flavor == "plain": + return "mochitest" + return flavor + + # This check can be removed when bug 983867 is fixed. + def isTest(self, options, filename): + allow_js_css = False + if options.flavor == "browser": + allow_js_css = True + testPattern = re.compile(r"browser_.+\.js") + elif options.flavor in ("a11y", "chrome"): + testPattern = re.compile(r"(browser|test)_.+\.(xul|html|js|xhtml)") + else: + testPattern = re.compile(r"test_") + + if not allow_js_css and (".js" in filename or ".css" in filename): + return False + + pathPieces = filename.split("/") + + return testPattern.match(pathPieces[-1]) and not re.search( + r"\^headers\^$", filename + ) + + def setTestRoot(self, options): + if options.flavor != "plain": + self.testRoot = options.flavor + else: + self.testRoot = self.TEST_PATH + self.testRootAbs = os.path.join(SCRIPT_DIR, self.testRoot) + + def buildTestURL(self, options, scheme="http"): + if scheme == "https": + testHost = "https://example.com:443" + elif options.xOriginTests: + testHost = "http://mochi.xorigin-test:8888" + else: + testHost = "http://mochi.test:8888" + testURL = "/".join([testHost, self.TEST_PATH]) + + if len(options.test_paths) == 1: + if os.path.isfile( + os.path.join( + self.oldcwd, + os.path.dirname(__file__), + self.TEST_PATH, + options.test_paths[0], + ) + ): + testURL = "/".join([testURL, os.path.dirname(options.test_paths[0])]) + else: + testURL = "/".join([testURL, options.test_paths[0]]) + + if options.flavor in ("a11y", "chrome"): + testURL = "/".join([testHost, self.CHROME_PATH]) + elif options.flavor == "browser": + testURL = "about:blank" + return testURL + + def parseAndCreateTestsDirs(self, m): + testsDirs = list(self.tests_dirs_by_manifest[m])[0] + self.extraTestsDirs = [] + if testsDirs: + self.extraTestsDirs = testsDirs.strip().split() + self.log.info( + "The following extra test directories will be created:\n {}".format( + "\n ".join(self.extraTestsDirs) + ) + ) + self.createExtraTestsDirs(self.extraTestsDirs, m) + + def createExtraTestsDirs(self, extraTestsDirs=None, manifest=None): + """Take a list of directories that might be needed to exist by the test + prior to even the main process be executed, and: + - verify it does not already exists + - create it if it does + Removal of those directories is handled in cleanup() + """ + if type(extraTestsDirs) != list: + return + + for d in extraTestsDirs: + if os.path.exists(d): + raise FileExistsError( + "Directory '{}' already exists. This is a member of " + "test-directories in manifest {}.".format(d, manifest) + ) + + created = [] + for d in extraTestsDirs: + os.makedirs(d) + created += [d] + + if created != extraTestsDirs: + raise EnvironmentError( + "Not all directories were created: extraTestsDirs={} -- created={}".format( + extraTestsDirs, created + ) + ) + + def getTestsByScheme( + self, options, testsToFilter=None, disabled=True, manifestToFilter=None + ): + """Build the url path to the specific test harness and test file or directory + Build a manifest of tests to run and write out a json file for the harness to read + testsToFilter option is used to filter/keep the tests provided in the list + + disabled -- This allows to add all disabled tests on the build side + and then on the run side to only run the enabled ones + """ + + tests = self.getActiveTests(options, disabled) + paths = [] + for test in tests: + if testsToFilter and (test["path"] not in testsToFilter): + continue + # If we are running a specific manifest, the previously computed set of active + # tests should be filtered out based on the manifest that contains that entry. + # + # This is especially important when a test file is listed in multiple + # manifests (e.g. because the same test runs under a different configuration, + # and so it is being included in multiple manifests), without filtering the + # active tests based on the current manifest (configuration) that we are + # running for each of the N manifests we would be executing the active tests + # exactly N times (and so NxN runs instead of the expected N runs, one for each + # manifest). + if manifestToFilter and (test["manifest"] not in manifestToFilter): + continue + paths.append(test) + + # Generate test by schemes + for (scheme, grouped_tests) in self.groupTestsByScheme(paths).items(): + # Bug 883865 - add this functionality into manifestparser + with open( + os.path.join(SCRIPT_DIR, options.testRunManifestFile), "w" + ) as manifestFile: + manifestFile.write(json.dumps({"tests": grouped_tests})) + options.manifestFile = options.testRunManifestFile + yield (scheme, grouped_tests) + + def startWebSocketServer(self, options, debuggerInfo): + """Launch the websocket server""" + self.wsserver = WebSocketServer(options, SCRIPT_DIR, self.log, debuggerInfo) + self.wsserver.start() + + def startWebServer(self, options): + """Create the webserver and start it up""" + + self.server = MochitestServer(options, self.log) + self.server.start() + + if options.pidFile != "": + with open(options.pidFile + ".xpcshell.pid", "w") as f: + f.write("%s" % self.server._process.pid) + + def startWebsocketProcessBridge(self, options): + """Create a websocket server that can launch various processes that + JS needs (eg; ICE server for webrtc testing) + """ + + command = [ + sys.executable, + os.path.join("websocketprocessbridge", "websocketprocessbridge.py"), + "--port", + options.websocket_process_bridge_port, + ] + self.websocketProcessBridge = mozprocess.ProcessHandler(command, cwd=SCRIPT_DIR) + self.websocketProcessBridge.run() + self.log.info( + "runtests.py | websocket/process bridge pid: %d" + % self.websocketProcessBridge.pid + ) + + # ensure the server is up, wait for at most ten seconds + for i in range(1, 100): + if self.websocketProcessBridge.proc.poll() is not None: + self.log.error( + "runtests.py | websocket/process bridge failed " + "to launch. Are all the dependencies installed?" + ) + return + + try: + sock = socket.create_connection(("127.0.0.1", 8191)) + sock.close() + break + except Exception: + time.sleep(0.1) + else: + self.log.error( + "runtests.py | Timed out while waiting for " + "websocket/process bridge startup." + ) + + def needsWebsocketProcessBridge(self, options): + """ + Returns a bool indicating if the current test configuration needs + to start the websocket process bridge or not. The boils down to if + WebRTC tests that need the bridge are present. + """ + tests = self.getActiveTests(options) + is_webrtc_tag_present = False + for test in tests: + if "webrtc" in test.get("tags", ""): + is_webrtc_tag_present = True + break + return is_webrtc_tag_present and options.subsuite in ["media"] + + def startHttp3Server(self, options): + """ + Start a Http3 test server. + """ + http3ServerPath = os.path.join( + options.utilityPath, "http3server" + mozinfo.info["bin_suffix"] + ) + serverOptions = {} + serverOptions["http3ServerPath"] = http3ServerPath + serverOptions["profilePath"] = options.profilePath + serverOptions["isMochitest"] = True + serverOptions["isWin"] = mozinfo.isWin + serverOptions["proxyPort"] = options.http3ServerPort + env = test_environment(xrePath=options.xrePath, log=self.log) + self.http3Server = Http3Server(serverOptions, env, self.log) + self.http3Server.start() + + port = self.http3Server.ports().get("MOZHTTP3_PORT_PROXY") + if int(port) != options.http3ServerPort: + self.http3Server = None + raise RuntimeError("Error: Unable to start Http/3 server") + + def startDoHServer(self, options): + """ + Start a DoH test server. + """ + # We try to find the node executable in the path given to us by the user in + # the MOZ_NODE_PATH environment variable + nodeBin = os.getenv("MOZ_NODE_PATH", None) + self.log.info("Use MOZ_NODE_PATH at %s" % (nodeBin)) + if not nodeBin and build: + nodeBin = build.substs.get("NODEJS") + self.log.info("Use build node at %s" % (nodeBin)) + + serverOptions = {} + serverOptions["serverPath"] = os.path.join( + SCRIPT_DIR, "DoHServer", "doh_server.js" + ) + serverOptions["nodeBin"] = nodeBin + serverOptions["dstServerPort"] = options.http3ServerPort + serverOptions["isWin"] = mozinfo.isWin + serverOptions["port"] = options.dohServerPort + env = test_environment(xrePath=options.xrePath, log=self.log) + self.dohServer = DoHServer(serverOptions, env, self.log) + self.dohServer.start() + + port = self.dohServer.port() + if port != options.dohServerPort: + raise RuntimeError("Error: Unable to start DoH server") + + def startServers(self, options, debuggerInfo, public=None): + # start servers and set ports + # TODO: pass these values, don't set on `self` + self.webServer = options.webServer + self.httpPort = options.httpPort + self.sslPort = options.sslPort + self.webSocketPort = options.webSocketPort + + # httpd-path is specified by standard makefile targets and may be specified + # on the command line to select a particular version of httpd.js. If not + # specified, try to select the one from hostutils.zip, as required in + # bug 882932. + if not options.httpdPath: + options.httpdPath = os.path.join(options.utilityPath, "components") + + self.startWebServer(options) + self.startWebSocketServer(options, debuggerInfo) + + # Only webrtc mochitests in the media suite need the websocketprocessbridge. + if self.needsWebsocketProcessBridge(options): + self.startWebsocketProcessBridge(options) + + # start SSL pipe + self.sslTunnel = SSLTunnel(options, logger=self.log) + self.sslTunnel.buildConfig(self.locations, public=public) + self.sslTunnel.start() + + # If we're lucky, the server has fully started by now, and all paths are + # ready, etc. However, xpcshell cold start times suck, at least for debug + # builds. We'll try to connect to the server for awhile, and if we fail, + # we'll try to kill the server and exit with an error. + if self.server is not None: + self.server.ensureReady(self.SERVER_STARTUP_TIMEOUT) + + self.log.info("use http3 server: %d" % options.useHttp3Server) + self.http3Server = None + self.dohServer = None + if options.useHttp3Server: + self.startHttp3Server(options) + self.startDoHServer(options) + + def stopServers(self): + """Servers are no longer needed, and perhaps more importantly, anything they + might spew to console might confuse things.""" + if self.server is not None: + try: + self.log.info("Stopping web server") + self.server.stop() + except Exception: + self.log.critical("Exception when stopping web server") + + if self.wsserver is not None: + try: + self.log.info("Stopping web socket server") + self.wsserver.stop() + except Exception: + self.log.critical("Exception when stopping web socket server") + + if self.sslTunnel is not None: + try: + self.log.info("Stopping ssltunnel") + self.sslTunnel.stop() + except Exception: + self.log.critical("Exception stopping ssltunnel") + + if self.websocketProcessBridge is not None: + try: + self.websocketProcessBridge.kill() + self.websocketProcessBridge.wait() + self.log.info("Stopping websocket/process bridge") + except Exception: + self.log.critical("Exception stopping websocket/process bridge") + if self.http3Server is not None: + try: + self.http3Server.stop() + except Exception: + self.log.critical("Exception stopping http3 server") + if self.dohServer is not None: + try: + self.dohServer.stop() + except Exception: + self.log.critical("Exception stopping doh server") + + if hasattr(self, "gstForV4l2loopbackProcess"): + try: + self.gstForV4l2loopbackProcess.kill() + self.gstForV4l2loopbackProcess.wait() + self.log.info("Stopping gst for v4l2loopback") + except Exception: + self.log.critical("Exception stopping gst for v4l2loopback") + + def copyExtraFilesToProfile(self, options): + "Copy extra files or dirs specified on the command line to the testing profile." + for f in options.extraProfileFiles: + abspath = self.getFullPath(f) + if os.path.isfile(abspath): + shutil.copy2(abspath, options.profilePath) + elif os.path.isdir(abspath): + dest = os.path.join(options.profilePath, os.path.basename(abspath)) + shutil.copytree(abspath, dest) + else: + self.log.warning("runtests.py | Failed to copy %s to profile" % abspath) + + def getChromeTestDir(self, options): + dir = os.path.join(os.path.abspath("."), SCRIPT_DIR) + "/" + if mozinfo.isWin: + dir = "file:///" + dir.replace("\\", "/") + return dir + + def writeChromeManifest(self, options): + manifest = os.path.join(options.profilePath, "tests.manifest") + with open(manifest, "w") as manifestFile: + # Register chrome directory. + chrometestDir = self.getChromeTestDir(options) + manifestFile.write( + "content mochitests %s contentaccessible=yes\n" % chrometestDir + ) + manifestFile.write( + "content mochitests-any %s contentaccessible=yes remoteenabled=yes\n" + % chrometestDir + ) + manifestFile.write( + "content mochitests-content %s contentaccessible=yes remoterequired=yes\n" + % chrometestDir + ) + + if options.testingModulesDir is not None: + manifestFile.write( + "resource testing-common file:///%s\n" % options.testingModulesDir + ) + if options.store_chrome_manifest: + shutil.copyfile(manifest, options.store_chrome_manifest) + return manifest + + def addChromeToProfile(self, options): + "Adds MochiKit chrome tests to the profile." + + # Create (empty) chrome directory. + chromedir = os.path.join(options.profilePath, "chrome") + os.mkdir(chromedir) + + # Write userChrome.css. + chrome = """ +/* set default namespace to XUL */ +@namespace url("http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"); +toolbar, +toolbarpalette { + background-color: rgb(235, 235, 235) !important; +} +toolbar#nav-bar { + background-image: none !important; +} +""" + with open( + os.path.join(options.profilePath, "userChrome.css"), "a" + ) as chromeFile: + chromeFile.write(chrome) + + manifest = self.writeChromeManifest(options) + + return manifest + + def getExtensionsToInstall(self, options): + "Return a list of extensions to install in the profile" + extensions = [] + appDir = ( + options.app[: options.app.rfind(os.sep)] + if options.app + else options.utilityPath + ) + + extensionDirs = [ + # Extensions distributed with the test harness. + os.path.normpath(os.path.join(SCRIPT_DIR, "extensions")), + ] + if appDir: + # Extensions distributed with the application. + extensionDirs.append(os.path.join(appDir, "distribution", "extensions")) + + for extensionDir in extensionDirs: + if os.path.isdir(extensionDir): + for dirEntry in os.listdir(extensionDir): + if dirEntry not in options.extensionsToExclude: + path = os.path.join(extensionDir, dirEntry) + if os.path.isdir(path) or ( + os.path.isfile(path) and path.endswith(".xpi") + ): + extensions.append(path) + extensions.extend(options.extensionsToInstall) + return extensions + + def logPreamble(self, tests): + """Logs a suite_start message and test_start/test_end at the beginning of a run.""" + self.log.suite_start( + self.tests_by_manifest, name="mochitest-{}".format(self.flavor) + ) + for test in tests: + if "disabled" in test: + self.log.test_start(test["path"]) + self.log.test_end(test["path"], "SKIP", message=test["disabled"]) + + def loadFailurePatternFile(self, pat_file): + if pat_file in self.patternFiles: + return self.patternFiles[pat_file] + if not os.path.isfile(pat_file): + self.log.warning( + "runtests.py | Cannot find failure pattern file " + pat_file + ) + return None + + # Using ":error" to ensure it shows up in the failure summary. + self.log.warning( + "[runtests.py:error] Using {} to filter failures. If there " + "is any number mismatch below, you could have fixed " + "something documented in that file. Please reduce the " + "failure count appropriately.".format(pat_file) + ) + patternRE = re.compile( + r""" + ^\s*\*\s* # list bullet + (test_\S+|\.{3}) # test name + (?:\s*(`.+?`|asserts))? # failure pattern + (?::.+)? # optional description + \s*\[(\d+|\*)\] # expected count + \s*$ + """, + re.X, + ) + patterns = {} + with open(pat_file) as f: + last_name = None + for line in f: + match = patternRE.match(line) + if not match: + continue + name = match.group(1) + name = last_name if name == "..." else name + last_name = name + pat = match.group(2) + if pat is not None: + pat = "ASSERTION" if pat == "asserts" else pat[1:-1] + count = match.group(3) + count = None if count == "*" else int(count) + if name not in patterns: + patterns[name] = [] + patterns[name].append((pat, count)) + self.patternFiles[pat_file] = patterns + return patterns + + def getFailurePatterns(self, pat_file, test_name): + patterns = self.loadFailurePatternFile(pat_file) + if patterns: + return patterns.get(test_name, None) + + def getActiveTests(self, options, disabled=True): + """ + This method is used to parse the manifest and return active filtered tests. + """ + if self._active_tests: + return self._active_tests + + tests = [] + manifest = self.getTestManifest(options) + if manifest: + if options.extra_mozinfo_json: + mozinfo.update(options.extra_mozinfo_json) + + info = mozinfo.info + + filters = [ + subsuite(options.subsuite), + ] + + # Allow for only running tests/manifests which match this tag + if options.conditionedProfile: + if not options.test_tags: + options.test_tags = [] + options.test_tags.append("condprof") + + if options.test_tags: + filters.append(tags(options.test_tags)) + + if options.test_paths: + options.test_paths = self.normalize_paths(options.test_paths) + filters.append(pathprefix(options.test_paths)) + + # Add chunking filters if specified + if options.totalChunks: + if options.chunkByDir: + filters.append( + chunk_by_dir( + options.thisChunk, options.totalChunks, options.chunkByDir + ) + ) + elif options.chunkByRuntime: + if mozinfo.info["os"] == "android": + platkey = "android" + elif mozinfo.isWin: + platkey = "windows" + else: + platkey = "unix" + + runtime_file = os.path.join( + SCRIPT_DIR, + "runtimes", + "manifest-runtimes-{}.json".format(platkey), + ) + if not os.path.exists(runtime_file): + self.log.error("runtime file %s not found!" % runtime_file) + sys.exit(1) + + # Given the mochitest flavor, load the runtimes information + # for only that flavor due to manifest runtime format change in Bug 1637463. + with open(runtime_file, "r") as f: + if "suite_name" in options: + runtimes = json.load(f).get(options.suite_name, {}) + else: + runtimes = {} + + filters.append( + chunk_by_runtime( + options.thisChunk, options.totalChunks, runtimes + ) + ) + else: + filters.append( + chunk_by_slice(options.thisChunk, options.totalChunks) + ) + + noDefaultFilters = False + if options.runFailures: + filters.append(failures(options.runFailures)) + noDefaultFilters = True + + tests = manifest.active_tests( + exists=False, + disabled=disabled, + filters=filters, + noDefaultFilters=noDefaultFilters, + **info + ) + + if len(tests) == 0: + self.log.error( + NO_TESTS_FOUND.format(options.flavor, manifest.fmt_filters()) + ) + + paths = [] + for test in tests: + if len(tests) == 1 and "disabled" in test: + del test["disabled"] + + pathAbs = os.path.abspath(test["path"]) + assert os.path.normcase(pathAbs).startswith( + os.path.normcase(self.testRootAbs) + ) + tp = pathAbs[len(self.testRootAbs) :].replace("\\", "/").strip("/") + + if not self.isTest(options, tp): + self.log.warning( + "Warning: %s from manifest %s is not a valid test" + % (test["name"], test["manifest"]) + ) + continue + + manifest_key = test["manifest_relpath"] + # Ignore ancestor_manifests that live at the root (e.g, don't have a + # path separator). + if "ancestor_manifest" in test and "/" in normsep( + test["ancestor_manifest"] + ): + manifest_key = "{}:{}".format(test["ancestor_manifest"], manifest_key) + + self.tests_by_manifest[manifest_key.replace("\\", "/")].append(tp) + self.args_by_manifest[manifest_key].add(test.get("args")) + self.prefs_by_manifest[manifest_key].add(test.get("prefs")) + self.env_vars_by_manifest[manifest_key].add(test.get("environment")) + self.tests_dirs_by_manifest[manifest_key].add(test.get("test-directories")) + + for key in ["args", "prefs", "environment", "test-directories"]: + if key in test and not options.runByManifest and "disabled" not in test: + self.log.error( + "parsing {}: runByManifest mode must be enabled to " + "set the `{}` key".format(test["manifest_relpath"], key) + ) + sys.exit(1) + + testob = {"path": tp, "manifest": manifest_key} + if "disabled" in test: + testob["disabled"] = test["disabled"] + if "expected" in test: + testob["expected"] = test["expected"] + if "https_first_disabled" in test: + testob["https_first_disabled"] = test["https_first_disabled"] == "true" + if "allow_xul_xbl" in test: + testob["allow_xul_xbl"] = test["allow_xul_xbl"] == "true" + if "scheme" in test: + testob["scheme"] = test["scheme"] + if "tags" in test: + testob["tags"] = test["tags"] + if options.failure_pattern_file: + pat_file = os.path.join( + os.path.dirname(test["manifest"]), options.failure_pattern_file + ) + patterns = self.getFailurePatterns(pat_file, test["name"]) + if patterns: + testob["expected"] = patterns + paths.append(testob) + + # The 'args' key needs to be set in the DEFAULT section, unfortunately + # we can't tell what comes from DEFAULT or not. So to validate this, we + # stash all args from tests in the same manifest into a set. If the + # length of the set > 1, then we know 'args' didn't come from DEFAULT. + args_not_default = [ + m for m, p in six.iteritems(self.args_by_manifest) if len(p) > 1 + ] + if args_not_default: + self.log.error( + "The 'args' key must be set in the DEFAULT section of a " + "manifest. Fix the following manifests: {}".format( + "\n".join(args_not_default) + ) + ) + sys.exit(1) + + # The 'prefs' key needs to be set in the DEFAULT section too. + pref_not_default = [ + m for m, p in six.iteritems(self.prefs_by_manifest) if len(p) > 1 + ] + if pref_not_default: + self.log.error( + "The 'prefs' key must be set in the DEFAULT section of a " + "manifest. Fix the following manifests: {}".format( + "\n".join(pref_not_default) + ) + ) + sys.exit(1) + # The 'environment' key needs to be set in the DEFAULT section too. + env_not_default = [ + m for m, p in six.iteritems(self.env_vars_by_manifest) if len(p) > 1 + ] + if env_not_default: + self.log.error( + "The 'environment' key must be set in the DEFAULT section of a " + "manifest. Fix the following manifests: {}".format( + "\n".join(env_not_default) + ) + ) + sys.exit(1) + + paths.sort(key=lambda p: p["path"].split("/")) + if options.dump_tests: + options.dump_tests = os.path.expanduser(options.dump_tests) + assert os.path.exists(os.path.dirname(options.dump_tests)) + with open(options.dump_tests, "w") as dumpFile: + dumpFile.write(json.dumps({"active_tests": paths})) + + self.log.info("Dumping active_tests to %s file." % options.dump_tests) + sys.exit() + + # Upload a list of test manifests that were executed in this run. + if "MOZ_UPLOAD_DIR" in os.environ: + artifact = os.path.join(os.environ["MOZ_UPLOAD_DIR"], "manifests.list") + with open(artifact, "a") as fh: + fh.write("\n".join(sorted(self.tests_by_manifest.keys()))) + + self._active_tests = paths + return self._active_tests + + def getTestManifest(self, options): + if isinstance(options.manifestFile, TestManifest): + manifest = options.manifestFile + elif options.manifestFile and os.path.isfile(options.manifestFile): + manifestFileAbs = os.path.abspath(options.manifestFile) + assert manifestFileAbs.startswith(SCRIPT_DIR) + manifest = TestManifest([options.manifestFile], strict=False) + elif options.manifestFile and os.path.isfile( + os.path.join(SCRIPT_DIR, options.manifestFile) + ): + manifestFileAbs = os.path.abspath( + os.path.join(SCRIPT_DIR, options.manifestFile) + ) + assert manifestFileAbs.startswith(SCRIPT_DIR) + manifest = TestManifest([manifestFileAbs], strict=False) + else: + masterName = self.normflavor(options.flavor) + ".ini" + masterPath = os.path.join(SCRIPT_DIR, self.testRoot, masterName) + + if os.path.exists(masterPath): + manifest = TestManifest([masterPath], strict=False) + else: + manifest = None + self.log.warning( + "TestManifest masterPath %s does not exist" % masterPath + ) + + return manifest + + def makeTestConfig(self, options): + "Creates a test configuration file for customizing test execution." + options.logFile = options.logFile.replace("\\", "\\\\") + + if ( + "MOZ_HIDE_RESULTS_TABLE" in os.environ + and os.environ["MOZ_HIDE_RESULTS_TABLE"] == "1" + ): + options.hideResultsTable = True + + # strip certain unnecessary items to avoid serialization errors in json.dumps() + d = dict( + (k, v) + for k, v in options.__dict__.items() + if (v is None) or isinstance(v, (six.string_types, numbers.Number)) + ) + d["testRoot"] = self.testRoot + if options.jscov_dir_prefix: + d["jscovDirPrefix"] = options.jscov_dir_prefix + if not options.keep_open: + d["closeWhenDone"] = "1" + + d["runFailures"] = False + if options.runFailures: + d["runFailures"] = True + content = json.dumps(d) + + shutil.copy( + os.path.join(SCRIPT_DIR, "ignorePrefs.json"), + os.path.join(options.profilePath, "ignorePrefs.json"), + ) + d["ignorePrefsFile"] = "ignorePrefs.json" + + with open(os.path.join(options.profilePath, "testConfig.js"), "w") as config: + config.write(content) + + def buildBrowserEnv(self, options, debugger=False, env=None): + """build the environment variables for the specific test and operating system""" + if mozinfo.info["asan"] and mozinfo.isLinux and mozinfo.bits == 64: + useLSan = True + else: + useLSan = False + + browserEnv = self.environment( + xrePath=options.xrePath, env=env, debugger=debugger, useLSan=useLSan + ) + + if hasattr(options, "topsrcdir"): + browserEnv["MOZ_DEVELOPER_REPO_DIR"] = options.topsrcdir + if hasattr(options, "topobjdir"): + browserEnv["MOZ_DEVELOPER_OBJ_DIR"] = options.topobjdir + + if options.headless: + browserEnv["MOZ_HEADLESS"] = "1" + + if options.dmd: + browserEnv["DMD"] = os.environ.get("DMD", "1") + + # bug 1443327: do not set MOZ_CRASHREPORTER_SHUTDOWN during browser-chrome + # tests, since some browser-chrome tests test content process crashes; + # also exclude non-e10s since at least one non-e10s mochitest is problematic + if ( + options.flavor == "browser" or not options.e10s + ) and "MOZ_CRASHREPORTER_SHUTDOWN" in browserEnv: + del browserEnv["MOZ_CRASHREPORTER_SHUTDOWN"] + + try: + browserEnv.update( + dict( + parse_key_value( + self.extraEnv, context="environment variable in manifest" + ) + ) + ) + except KeyValueParseError as e: + self.log.error(str(e)) + return None + + # These variables are necessary for correct application startup; change + # via the commandline at your own risk. + browserEnv["XPCOM_DEBUG_BREAK"] = "stack" + + # interpolate environment passed with options + try: + browserEnv.update( + dict(parse_key_value(options.environment, context="--setenv")) + ) + except KeyValueParseError as e: + self.log.error(str(e)) + return None + + if ( + "MOZ_PROFILER_STARTUP_FEATURES" not in browserEnv + or "nativeallocations" + not in browserEnv["MOZ_PROFILER_STARTUP_FEATURES"].split(",") + ): + # Only turn on the bloat log if the profiler's native allocation feature is + # not enabled. The two are not compatible. + browserEnv["XPCOM_MEM_BLOAT_LOG"] = self.leak_report_file + + # If profiling options are enabled, turn on the gecko profiler by using the + # profiler environmental variables. + if options.profiler: + # The user wants to capture a profile, and automatically view it. The + # profile will be saved to a temporary folder, then deleted after + # opening in profiler.firefox.com. + self.profiler_tempdir = tempfile.mkdtemp() + browserEnv["MOZ_PROFILER_SHUTDOWN"] = os.path.join( + self.profiler_tempdir, "mochitest-profile.json" + ) + browserEnv["MOZ_PROFILER_STARTUP"] = "1" + + if options.profilerSaveOnly: + # The user wants to capture a profile, but only to save it. This defaults + # to the MOZ_UPLOAD_DIR. + browserEnv["MOZ_PROFILER_STARTUP"] = "1" + if "MOZ_UPLOAD_DIR" in browserEnv: + browserEnv["MOZ_PROFILER_SHUTDOWN"] = os.path.join( + browserEnv["MOZ_UPLOAD_DIR"], "mochitest-profile.json" + ) + else: + self.log.error( + "--profiler-save-only was specified, but no MOZ_UPLOAD_DIR " + "environment variable was provided. Please set this " + "environment variable to a directory path in order to save " + "a performance profile." + ) + return None + + try: + gmp_path = self.getGMPPluginPath(options) + if gmp_path is not None: + browserEnv["MOZ_GMP_PATH"] = gmp_path + except EnvironmentError: + self.log.error("Could not find path to gmp-fake plugin!") + return None + + if options.fatalAssertions: + browserEnv["XPCOM_DEBUG_BREAK"] = "stack-and-abort" + + # Produce a mozlog, if setup (see MOZ_LOG global at the top of + # this script). + self.mozLogs = MOZ_LOG and "MOZ_UPLOAD_DIR" in os.environ + if self.mozLogs: + browserEnv["MOZ_LOG"] = MOZ_LOG + + return browserEnv + + def killNamedProc(self, pname, orphans=True): + """Kill processes matching the given command name""" + self.log.info("Checking for %s processes..." % pname) + + if HAVE_PSUTIL: + for proc in psutil.process_iter(): + try: + if proc.name() == pname: + procd = proc.as_dict(attrs=["pid", "ppid", "name", "username"]) + if proc.ppid() == 1 or not orphans: + self.log.info("killing %s" % procd) + killPid(proc.pid, self.log) + else: + self.log.info("NOT killing %s (not an orphan?)" % procd) + except Exception as e: + self.log.info( + "Warning: Unable to kill process %s: %s" % (pname, str(e)) + ) + # may not be able to access process info for all processes + continue + else: + + def _psInfo(line): + if pname in line: + self.log.info(line) + + process = mozprocess.ProcessHandler( + ["ps", "-f"], processOutputLine=_psInfo, universal_newlines=True + ) + process.run() + process.wait() + + def _psKill(line): + parts = line.split() + if len(parts) == 3 and parts[0].isdigit(): + pid = int(parts[0]) + ppid = int(parts[1]) + if parts[2] == pname: + if ppid == 1 or not orphans: + self.log.info("killing %s (pid %d)" % (pname, pid)) + killPid(pid, self.log) + else: + self.log.info( + "NOT killing %s (pid %d) (not an orphan?)" + % (pname, pid) + ) + + process = mozprocess.ProcessHandler( + ["ps", "-o", "pid,ppid,comm"], + processOutputLine=_psKill, + universal_newlines=True, + ) + process.run() + process.wait() + + def execute_start_script(self): + if not self.start_script or not self.marionette: + return + + if os.path.isfile(self.start_script): + with open(self.start_script, "r") as fh: + script = fh.read() + else: + script = self.start_script + + with self.marionette.using_context("chrome"): + return self.marionette.execute_script( + script, script_args=(self.start_script_kwargs,) + ) + + def fillCertificateDB(self, options): + # TODO: move -> mozprofile: + # https://bugzilla.mozilla.org/show_bug.cgi?id=746243#c35 + + pwfilePath = os.path.join(options.profilePath, ".crtdbpw") + with open(pwfilePath, "w") as pwfile: + pwfile.write("\n") + + # Pre-create the certification database for the profile + env = self.environment(xrePath=options.xrePath) + env["LD_LIBRARY_PATH"] = options.xrePath + bin_suffix = mozinfo.info.get("bin_suffix", "") + certutil = os.path.join(options.utilityPath, "certutil" + bin_suffix) + pk12util = os.path.join(options.utilityPath, "pk12util" + bin_suffix) + toolsEnv = env + if mozinfo.info["asan"]: + # Disable leak checking when running these tools + toolsEnv["ASAN_OPTIONS"] = "detect_leaks=0" + if mozinfo.info["tsan"]: + # Disable race checking when running these tools + toolsEnv["TSAN_OPTIONS"] = "report_bugs=0" + + if self.certdbNew: + # android uses the new DB formats exclusively + certdbPath = "sql:" + options.profilePath + else: + # desktop seems to use the old + certdbPath = options.profilePath + + # certutil.exe depends on some DLLs in the app directory + # When running tests against an MSIX-installed Firefox, these DLLs + # cannot be used out of the install directory, they must be copied + # elsewhere first. + if "WindowsApps" in options.app: + install_dir = os.path.dirname(options.app) + for f in os.listdir(install_dir): + if f.endswith(".dll"): + shutil.copy(os.path.join(install_dir, f), options.utilityPath) + + status = call( + [certutil, "-N", "-d", certdbPath, "-f", pwfilePath], env=toolsEnv + ) + if status: + return status + + # Walk the cert directory and add custom CAs and client certs + files = os.listdir(options.certPath) + for item in files: + root, ext = os.path.splitext(item) + if ext == ".ca": + trustBits = "CT,," + if root.endswith("-object"): + trustBits = "CT,,CT" + call( + [ + certutil, + "-A", + "-i", + os.path.join(options.certPath, item), + "-d", + certdbPath, + "-f", + pwfilePath, + "-n", + root, + "-t", + trustBits, + ], + env=toolsEnv, + ) + elif ext == ".client": + call( + [ + pk12util, + "-i", + os.path.join(options.certPath, item), + "-w", + pwfilePath, + "-d", + certdbPath, + ], + env=toolsEnv, + ) + + os.unlink(pwfilePath) + return 0 + + def findFreePort(self, type): + with closing(socket.socket(socket.AF_INET, type)) as s: + s.bind(("127.0.0.1", 0)) + s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + return s.getsockname()[1] + + def proxy(self, options): + # proxy + # use SSL port for legacy compatibility; see + # - https://bugzilla.mozilla.org/show_bug.cgi?id=688667#c66 + # - https://bugzilla.mozilla.org/show_bug.cgi?id=899221 + # - https://github.com/mozilla/mozbase/commit/43f9510e3d58bfed32790c82a57edac5f928474d + # 'ws': str(self.webSocketPort) + proxyOptions = { + "remote": options.webServer, + "http": options.httpPort, + "https": options.sslPort, + "ws": options.sslPort, + } + + if options.useHttp3Server: + options.dohServerPort = self.findFreePort(socket.SOCK_STREAM) + options.http3ServerPort = self.findFreePort(socket.SOCK_DGRAM) + proxyOptions["dohServerPort"] = options.dohServerPort + self.log.info("use doh server at port: %d" % options.dohServerPort) + self.log.info("use http3 server at port: %d" % options.http3ServerPort) + return proxyOptions + + def merge_base_profiles(self, options, category): + """Merge extra profile data from testing/profiles.""" + + # In test packages used in CI, the profile_data directory is installed + # in the SCRIPT_DIR. + profile_data_dir = os.path.join(SCRIPT_DIR, "profile_data") + # If possible, read profile data from topsrcdir. This prevents us from + # requiring a re-build to pick up newly added extensions in the + # <profile>/extensions directory. + if build_obj: + path = os.path.join(build_obj.topsrcdir, "testing", "profiles") + if os.path.isdir(path): + profile_data_dir = path + # Still not found? Look for testing/profiles relative to testing/mochitest. + if not os.path.isdir(profile_data_dir): + path = os.path.abspath(os.path.join(SCRIPT_DIR, "..", "profiles")) + if os.path.isdir(path): + profile_data_dir = path + + with open(os.path.join(profile_data_dir, "profiles.json"), "r") as fh: + base_profiles = json.load(fh)[category] + + # values to use when interpolating preferences + interpolation = { + "server": "%s:%s" % (options.webServer, options.httpPort), + } + + for profile in base_profiles: + path = os.path.join(profile_data_dir, profile) + self.profile.merge(path, interpolation=interpolation) + + @property + def conditioned_profile_copy(self): + """Returns a copy of the original conditioned profile that was created.""" + + condprof_copy = os.path.join(tempfile.mkdtemp(), "profile") + shutil.copytree( + self.conditioned_profile_dir, + condprof_copy, + ignore=shutil.ignore_patterns("lock"), + ) + self.log.info("Created a conditioned-profile copy: %s" % condprof_copy) + return condprof_copy + + def downloadConditionedProfile(self, profile_scenario, app): + from condprof.client import get_profile + from condprof.util import get_current_platform, get_version + + if self.conditioned_profile_dir: + # We already have a directory, so provide a copy that + # will get deleted after it's done with + return self.conditioned_profile_copy + + temp_download_dir = tempfile.mkdtemp() + + # Call condprof's client API to yield our platform-specific + # conditioned-profile binary + platform = get_current_platform() + + if not profile_scenario: + profile_scenario = "settled" + + version = get_version(app) + try: + cond_prof_target_dir = get_profile( + temp_download_dir, + platform, + profile_scenario, + repo="mozilla-central", + version=version, + retries=2, # quicker failure + ) + except Exception: + if version is None: + # any other error is a showstopper + self.log.critical("Could not get the conditioned profile") + traceback.print_exc() + raise + version = None + try: + self.log.info("retrying a profile with no version specified") + cond_prof_target_dir = get_profile( + temp_download_dir, + platform, + profile_scenario, + repo="mozilla-central", + version=version, + ) + except Exception: + self.log.critical("Could not get the conditioned profile") + traceback.print_exc() + raise + + # Now get the full directory path to our fetched conditioned profile + self.conditioned_profile_dir = os.path.join( + temp_download_dir, cond_prof_target_dir + ) + if not os.path.exists(cond_prof_target_dir): + self.log.critical( + "Can't find target_dir {}, from get_profile()" + "temp_download_dir {}, platform {}, scenario {}".format( + cond_prof_target_dir, temp_download_dir, platform, profile_scenario + ) + ) + raise OSError + + self.log.info( + "Original self.conditioned_profile_dir is now set: {}".format( + self.conditioned_profile_dir + ) + ) + return self.conditioned_profile_copy + + def buildProfile(self, options): + """create the profile and add optional chrome bits and files if requested""" + # get extensions to install + extensions = self.getExtensionsToInstall(options) + + # Whitelist the _tests directory (../..) so that TESTING_JS_MODULES work + tests_dir = os.path.dirname(os.path.dirname(SCRIPT_DIR)) + sandbox_whitelist_paths = [tests_dir] + options.sandboxReadWhitelist + if platform.system() == "Linux" or platform.system() in ( + "Windows", + "Microsoft", + ): + # Trailing slashes are needed to indicate directories on Linux and Windows + sandbox_whitelist_paths = [ + os.path.join(p, "") for p in sandbox_whitelist_paths + ] + + if options.conditionedProfile: + if options.profilePath and os.path.exists(options.profilePath): + shutil.rmtree(options.profilePath, ignore_errors=True) + options.profilePath = self.downloadConditionedProfile("full", options.app) + + # This is causing `certutil -N -d -f`` to not use -f (pwd file) + try: + os.remove(os.path.join(options.profilePath, "key4.db")) + except Exception as e: + self.log.info( + "Caught exception while removing key4.db" + "during setup of conditioned profile: %s" % e + ) + + # Create the profile + self.profile = Profile( + profile=options.profilePath, + addons=extensions, + locations=self.locations, + proxy=self.proxy(options), + whitelistpaths=sandbox_whitelist_paths, + ) + + # Fix options.profilePath for legacy consumers. + options.profilePath = self.profile.profile + + manifest = self.addChromeToProfile(options) + self.copyExtraFilesToProfile(options) + + # create certificate database for the profile + # TODO: this should really be upstreamed somewhere, maybe mozprofile + certificateStatus = self.fillCertificateDB(options) + if certificateStatus: + self.log.error( + "TEST-UNEXPECTED-FAIL | runtests.py | Certificate integration failed" + ) + return None + + # Set preferences in the following order (latter overrides former): + # 1) Preferences from base profile (e.g from testing/profiles) + # 2) Prefs hardcoded in this function + # 3) Prefs from --setpref + + # Prefs from base profiles + self.merge_base_profiles(options, "mochitest") + + # Hardcoded prefs (TODO move these into a base profile) + prefs = { + "browser.tabs.remote.autostart": options.e10s, + # Enable tracing output for detailed failures in case of + # failing connection attempts, and hangs (bug 1397201) + "remote.log.level": "Trace", + # Disable async font fallback, because the unpredictable + # extra reflow it can trigger (potentially affecting a later + # test) results in spurious intermittent failures. + "gfx.font_rendering.fallback.async": False, + } + + test_timeout = None + if options.flavor == "browser" and options.timeout: + test_timeout = options.timeout + + # browser-chrome tests use a fairly short default timeout of 45 seconds; + # this is sometimes too short on asan and debug, where we expect reduced + # performance. + if ( + (mozinfo.info["asan"] or mozinfo.info["debug"]) + and options.flavor == "browser" + and options.timeout is None + ): + self.log.info("Increasing default timeout to 90 seconds (asan or debug)") + test_timeout = 90 + + # tsan builds need even more time + if ( + mozinfo.info["tsan"] + and options.flavor == "browser" + and options.timeout is None + ): + self.log.info("Increasing default timeout to 120 seconds (tsan)") + test_timeout = 120 + + if mozinfo.info["os"] == "win" and mozinfo.info["processor"] == "aarch64": + test_timeout = self.DEFAULT_TIMEOUT * 4 + self.log.info( + "Increasing default timeout to {} seconds (win aarch64)".format( + test_timeout + ) + ) + + if "MOZ_CHAOSMODE=0xfb" in options.environment and test_timeout: + test_timeout *= 2 + self.log.info( + "Increasing default timeout to {} seconds (MOZ_CHAOSMODE)".format( + test_timeout + ) + ) + + if test_timeout: + prefs["testing.browserTestHarness.timeout"] = test_timeout + + if getattr(self, "testRootAbs", None): + prefs["mochitest.testRoot"] = self.testRootAbs + + # See if we should use fake media devices. + if options.useTestMediaDevices: + prefs["media.audio_loopback_dev"] = self.mediaDevices["audio"]["name"] + prefs["media.video_loopback_dev"] = self.mediaDevices["video"]["name"] + prefs["media.cubeb.output_device"] = "Null Output" + prefs["media.volume_scale"] = "1.0" + self.gstForV4l2loopbackProcess = self.mediaDevices["video"]["process"] + + self.profile.set_preferences(prefs) + + # Extra prefs from --setpref + self.profile.set_preferences(self.extraPrefs) + return manifest + + def getGMPPluginPath(self, options): + if options.gmp_path: + return options.gmp_path + + gmp_parentdirs = [ + # For local builds, GMP plugins will be under dist/bin. + options.xrePath, + # For packaged builds, GMP plugins will get copied under + # $profile/plugins. + os.path.join(self.profile.profile, "plugins"), + ] + + gmp_subdirs = [ + os.path.join("gmp-fake", "1.0"), + os.path.join("gmp-fakeopenh264", "1.0"), + os.path.join("gmp-clearkey", "0.1"), + ] + + gmp_paths = [ + os.path.join(parent, sub) + for parent in gmp_parentdirs + for sub in gmp_subdirs + if os.path.isdir(os.path.join(parent, sub)) + ] + + if not gmp_paths: + # This is fatal for desktop environments. + raise EnvironmentError("Could not find test gmp plugins") + + return os.pathsep.join(gmp_paths) + + def cleanup(self, options, final=False): + """remove temporary files, profile and virtual audio input device""" + if hasattr(self, "manifest") and self.manifest is not None: + if os.path.exists(self.manifest): + os.remove(self.manifest) + if hasattr(self, "profile"): + del self.profile + if hasattr(self, "extraTestsDirs"): + for d in self.extraTestsDirs: + if os.path.exists(d): + shutil.rmtree(d) + if options.pidFile != "" and os.path.exists(options.pidFile): + try: + os.remove(options.pidFile) + if os.path.exists(options.pidFile + ".xpcshell.pid"): + os.remove(options.pidFile + ".xpcshell.pid") + except Exception: + self.log.warning( + "cleaning up pidfile '%s' was unsuccessful from the test harness" + % options.pidFile + ) + options.manifestFile = None + + if hasattr(self, "virtualInputDeviceIdList"): + pactl = spawn.find_executable("pactl") + + if not pactl: + self.log.error("Could not find pactl on system") + return None + + for id in self.virtualInputDeviceIdList: + try: + subprocess.check_call([pactl, "unload-module", str(id)]) + except subprocess.CalledProcessError: + self.log.error( + "Could not remove pulse module with id {}".format(id) + ) + return None + + self.virtualInputDeviceIdList = [] + + def dumpScreen(self, utilityPath): + if self.haveDumpedScreen: + self.log.info( + "Not taking screenshot here: see the one that was previously logged" + ) + return + self.haveDumpedScreen = True + dump_screen(utilityPath, self.log) + + def killAndGetStack(self, processPID, utilityPath, debuggerInfo, dump_screen=False): + """ + Kill the process, preferrably in a way that gets us a stack trace. + Also attempts to obtain a screenshot before killing the process + if specified. + """ + self.log.info("Killing process: %s" % processPID) + if dump_screen: + self.dumpScreen(utilityPath) + + if mozinfo.info.get("crashreporter", True) and not debuggerInfo: + try: + minidump_path = os.path.join(self.profile.profile, "minidumps") + mozcrash.kill_and_get_minidump(processPID, minidump_path, utilityPath) + except OSError: + # https://bugzilla.mozilla.org/show_bug.cgi?id=921509 + self.log.info("Can't trigger Breakpad, process no longer exists") + return + self.log.info("Can't trigger Breakpad, just killing process") + killPid(processPID, self.log) + + def extract_child_pids(self, process_log, parent_pid=None): + """Parses the given log file for the pids of any processes launched by + the main process and returns them as a list. + If parent_pid is provided, and psutil is available, returns children of + parent_pid according to psutil. + """ + rv = [] + if parent_pid and HAVE_PSUTIL: + self.log.info("Determining child pids from psutil...") + try: + rv = [p.pid for p in psutil.Process(parent_pid).children()] + self.log.info(str(rv)) + except psutil.NoSuchProcess: + self.log.warning("Failed to lookup children of pid %d" % parent_pid) + + rv = set(rv) + pid_re = re.compile(r"==> process \d+ launched child process (\d+)") + with open(process_log) as fd: + for line in fd: + self.log.info(line.rstrip()) + m = pid_re.search(line) + if m: + rv.add(int(m.group(1))) + return rv + + def checkForZombies(self, processLog, utilityPath, debuggerInfo): + """Look for hung processes""" + + if not os.path.exists(processLog): + self.log.info("Automation Error: PID log not found: %s" % processLog) + # Whilst no hung process was found, the run should still display as + # a failure + return True + + # scan processLog for zombies + self.log.info("zombiecheck | Reading PID log: %s" % processLog) + processList = self.extract_child_pids(processLog) + # kill zombies + foundZombie = False + for processPID in processList: + self.log.info( + "zombiecheck | Checking for orphan process with PID: %d" % processPID + ) + if isPidAlive(processPID): + foundZombie = True + self.log.error( + "TEST-UNEXPECTED-FAIL | zombiecheck | child process " + "%d still alive after shutdown" % processPID + ) + self.killAndGetStack( + processPID, utilityPath, debuggerInfo, dump_screen=not debuggerInfo + ) + + return foundZombie + + def checkForRunningBrowsers(self): + firefoxes = "" + if HAVE_PSUTIL: + attrs = ["pid", "ppid", "name", "cmdline", "username"] + for proc in psutil.process_iter(): + try: + if "firefox" in proc.name(): + firefoxes = "%s%s\n" % (firefoxes, proc.as_dict(attrs=attrs)) + except Exception: + # may not be able to access process info for all processes + continue + if len(firefoxes) > 0: + # In automation, this warning is unexpected and should be investigated. + # In local testing, this is probably okay, as long as the browser is not + # running a marionette server. + self.log.warning("Found 'firefox' running before starting test browser!") + self.log.warning(firefoxes) + + def runApp( + self, + testUrl, + env, + app, + profile, + extraArgs, + utilityPath, + debuggerInfo=None, + valgrindPath=None, + valgrindArgs=None, + valgrindSuppFiles=None, + symbolsPath=None, + timeout=-1, + detectShutdownLeaks=False, + screenshotOnFail=False, + bisectChunk=None, + marionette_args=None, + e10s=True, + runFailures=False, + crashAsPass=False, + currentManifest=None, + ): + """ + Run the app, log the duration it took to execute, return the status code. + Kills the app if it runs for longer than |maxTime| seconds, or outputs nothing + for |timeout| seconds. + """ + # It can't be the case that both a with-debugger and an + # on-Valgrind run have been requested. doTests() should have + # already excluded this possibility. + assert not (valgrindPath and debuggerInfo) + + # debugger information + interactive = False + debug_args = None + if debuggerInfo: + interactive = debuggerInfo.interactive + debug_args = [debuggerInfo.path] + debuggerInfo.args + + # Set up Valgrind arguments. + if valgrindPath: + interactive = False + valgrindArgs_split = ( + [] if valgrindArgs is None else shlex.split(valgrindArgs) + ) + + valgrindSuppFiles_final = [] + if valgrindSuppFiles is not None: + valgrindSuppFiles_final = [ + "--suppressions=" + path for path in valgrindSuppFiles.split(",") + ] + + debug_args = ( + [valgrindPath] + + mozdebug.get_default_valgrind_args() + + valgrindArgs_split + + valgrindSuppFiles_final + ) + + # fix default timeout + if timeout == -1: + timeout = self.DEFAULT_TIMEOUT + + # Note in the log if running on Valgrind + if valgrindPath: + self.log.info( + "runtests.py | Running on Valgrind. " + + "Using timeout of %d seconds." % timeout + ) + + # copy env so we don't munge the caller's environment + env = env.copy() + + # Used to defer a possible IOError exception from Marionette + marionette_exception = None + + temp_file_paths = [] + + # make sure we clean up after ourselves. + try: + # set process log environment variable + tmpfd, processLog = tempfile.mkstemp(suffix="pidlog") + os.close(tmpfd) + env["MOZ_PROCESS_LOG"] = processLog + + if debuggerInfo: + # If a debugger is attached, don't use timeouts, and don't + # capture ctrl-c. + timeout = None + signal.signal(signal.SIGINT, lambda sigid, frame: None) + + # build command line + cmd = os.path.abspath(app) + args = list(extraArgs) + args.append("-marionette") + # TODO: mozrunner should use -foreground at least for mac + # https://bugzilla.mozilla.org/show_bug.cgi?id=916512 + args.append("-foreground") + self.start_script_kwargs["testUrl"] = testUrl or "about:blank" + + if detectShutdownLeaks: + env["MOZ_LOG"] = ( + env["MOZ_LOG"] + "," if env["MOZ_LOG"] else "" + ) + "DocShellAndDOMWindowLeak:3" + shutdownLeaks = ShutdownLeaks(self.log) + else: + shutdownLeaks = None + + if mozinfo.info["asan"] and mozinfo.isLinux and mozinfo.bits == 64: + lsanLeaks = LSANLeaks(self.log) + else: + lsanLeaks = None + + # create an instance to process the output + outputHandler = self.OutputHandler( + harness=self, + utilityPath=utilityPath, + symbolsPath=symbolsPath, + dump_screen_on_timeout=not debuggerInfo, + dump_screen_on_fail=screenshotOnFail, + shutdownLeaks=shutdownLeaks, + lsanLeaks=lsanLeaks, + bisectChunk=bisectChunk, + ) + + def timeoutHandler(): + browserProcessId = outputHandler.browserProcessId + self.handleTimeout( + timeout, + proc, + utilityPath, + debuggerInfo, + browserProcessId, + processLog, + ) + + kp_kwargs = { + "kill_on_timeout": False, + "cwd": SCRIPT_DIR, + "onTimeout": [timeoutHandler], + } + kp_kwargs["processOutputLine"] = [outputHandler] + + self.checkForRunningBrowsers() + + # create mozrunner instance and start the system under test process + self.lastTestSeen = self.test_name + startTime = datetime.now() + + runner_cls = mozrunner.runners.get( + mozinfo.info.get("appname", "firefox"), mozrunner.Runner + ) + runner = runner_cls( + profile=self.profile, + binary=cmd, + cmdargs=args, + env=env, + process_class=mozprocess.ProcessHandlerMixin, + process_args=kp_kwargs, + ) + + # start the runner + try: + runner.start( + debug_args=debug_args, + interactive=interactive, + outputTimeout=timeout, + ) + proc = runner.process_handler + self.log.info("runtests.py | Application pid: %d" % proc.pid) + + gecko_id = "GECKO(%d)" % proc.pid + self.log.process_start(gecko_id) + self.message_logger.gecko_id = gecko_id + except PermissionError: + # treat machine as bad, return + return TBPL_RETRY, "Failure to launch browser" + except Exception as e: + raise e # unknown error + + try: + # start marionette and kick off the tests + marionette_args = marionette_args or {} + self.marionette = Marionette(**marionette_args) + self.marionette.start_session() + + # install specialpowers and mochikit addons + addons = Addons(self.marionette) + + if self.staged_addons: + for addon_path in self.staged_addons: + if not os.path.isdir(addon_path): + self.log.error( + "TEST-UNEXPECTED-FAIL | invalid setup: missing extension at %s" + % addon_path + ) + return 1, self.lastTestSeen + temp_addon_path = create_zip(addon_path) + temp_file_paths.append(temp_addon_path) + addons.install(temp_addon_path) + + self.execute_start_script() + + # an open marionette session interacts badly with mochitest, + # delete it until we figure out why. + self.marionette.delete_session() + del self.marionette + + except IOError: + # Any IOError as thrown by Marionette means that something is + # wrong with the process, like a crash or the socket is no + # longer open. We defer raising this specific error so that + # post-test checks for leaks and crashes are performed and + # reported first. + marionette_exception = sys.exc_info() + + # wait until app is finished + # XXX copy functionality from + # https://github.com/mozilla/mozbase/blob/master/mozrunner/mozrunner/runner.py#L61 + # until bug 913970 is fixed regarding mozrunner `wait` not returning status + # see https://bugzilla.mozilla.org/show_bug.cgi?id=913970 + self.log.info("runtests.py | Waiting for browser...") + status = proc.wait() + if status is None: + self.log.warning( + "runtests.py | Failed to get app exit code - running/crashed?" + ) + # must report an integer to process_exit() + status = 0 + self.log.process_exit("Main app process", status) + runner.process_handler = None + + # finalize output handler + outputHandler.finish() + + # record post-test information + if status: + # no need to keep return code 137, 245, etc. + status = 1 + self.message_logger.dump_buffered() + msg = "application terminated with exit code %s" % status + # self.message_logger.is_test_running indicates we need to send a test_end + if crashAsPass and self.message_logger.is_test_running: + # this works for browser-chrome, mochitest-plain has status=0 + message = { + "action": "test_end", + "status": "CRASH", + "expected": "CRASH", + "thread": None, + "pid": None, + "source": "mochitest", + "time": int(time.time()) * 1000, + "test": self.lastTestSeen, + "message": msg, + } + # need to send a test_end in order to have mozharness process messages properly + # this requires a custom message vs log.error/log.warning/etc. + self.message_logger.process_message(message) + else: + self.lastTestSeen = ( + currentManifest or "Main app process exited normally" + ) + + self.log.info( + "runtests.py | Application ran for: %s" + % str(datetime.now() - startTime) + ) + + # Do a final check for zombie child processes. + zombieProcesses = self.checkForZombies( + processLog, utilityPath, debuggerInfo + ) + + # check for crashes + quiet = False + if crashAsPass: + quiet = True + + minidump_path = os.path.join(self.profile.profile, "minidumps") + crash_count = mozcrash.log_crashes( + self.log, + minidump_path, + symbolsPath, + test=self.lastTestSeen, + quiet=quiet, + ) + + expected = None + if crashAsPass or crash_count > 0: + # self.message_logger.is_test_running indicates we need a test_end message + if self.message_logger.is_test_running: + # this works for browser-chrome, mochitest-plain has status=0 + expected = "CRASH" + if crashAsPass: + status = 0 + elif crash_count or zombieProcesses: + if self.message_logger.is_test_running: + expected = "PASS" + status = 1 + + if expected: + # send this out so we always wrap up the test-end message + message = { + "action": "test_end", + "status": "CRASH", + "expected": expected, + "thread": None, + "pid": None, + "source": "mochitest", + "time": int(time.time()) * 1000, + "test": self.lastTestSeen, + "message": "application terminated with exit code %s" % status, + } + # need to send a test_end in order to have mozharness process messages properly + # this requires a custom message vs log.error/log.warning/etc. + self.message_logger.process_message(message) + finally: + # cleanup + if os.path.exists(processLog): + os.remove(processLog) + for p in temp_file_paths: + os.remove(p) + + if marionette_exception is not None: + exc, value, tb = marionette_exception + six.reraise(exc, value, tb) + + return status, self.lastTestSeen + + def initializeLooping(self, options): + """ + This method is used to clear the contents before each run of for loop. + This method is used for --run-by-dir and --bisect-chunk. + """ + if options.conditionedProfile: + if options.profilePath and os.path.exists(options.profilePath): + shutil.rmtree(options.profilePath, ignore_errors=True) + if options.manifestFile and os.path.exists(options.manifestFile): + os.remove(options.manifestFile) + + self.expectedError.clear() + self.result.clear() + options.manifestFile = None + options.profilePath = None + + def initializeVirtualInputDevices(self): + """ + Configure the system to have a number of virtual audio input devices, that + each produce a tone at a particular frequency. + + This method is only currently implemented for Linux. + """ + if not mozinfo.isLinux: + return + + pactl = spawn.find_executable("pactl") + + if not pactl: + self.log.error("Could not find pactl on system") + return + + DEVICES_COUNT = 4 + DEVICES_BASE_FREQUENCY = 110 # Hz + self.virtualInputDeviceIdList = [] + # If the device are already present, find their id and return early + o = subprocess.check_output([pactl, "list", "modules", "short"]) + found_devices = 0 + for input in o.splitlines(): + device = input.decode().split("\t") + if device[1] == "module-sine-source": + self.virtualInputDeviceIdList.append(int(device[0])) + found_devices += 1 + + if found_devices == DEVICES_COUNT: + return + elif found_devices != 0: + # Remove all devices and reinitialize them properly + for id in self.virtualInputDeviceIdList: + try: + subprocess.check_call([pactl, "unload-module", str(id)]) + except subprocess.CalledProcessError: + log.error("Could not remove pulse module with id {}".format(id)) + return None + + # We want quite a number of input devices, each with a different tone + # frequency and device name so that we can recognize them easily during + # testing. + command = [pactl, "load-module", "module-sine-source", "rate=44100"] + for i in range(1, DEVICES_COUNT + 1): + freq = i * DEVICES_BASE_FREQUENCY + complete_command = command + [ + "source_name=sine-{}".format(freq), + "frequency={}".format(freq), + ] + try: + o = subprocess.check_output(complete_command) + self.virtualInputDeviceIdList.append(o) + + except subprocess.CalledProcessError: + self.log.error( + "Could not create device with module-sine-source" + " (freq={})".format(freq) + ) + + def normalize_paths(self, paths): + # Normalize test paths so they are relative to test root + norm_paths = [] + for p in paths: + abspath = os.path.abspath(os.path.join(self.oldcwd, p)) + if abspath.startswith(self.testRootAbs): + norm_paths.append(os.path.relpath(abspath, self.testRootAbs)) + else: + norm_paths.append(p) + return norm_paths + + def runMochitests(self, options, testsToRun, manifestToFilter=None): + "This is a base method for calling other methods in this class for --bisect-chunk." + # Making an instance of bisect class for --bisect-chunk option. + bisect = bisection.Bisect(self) + finished = False + status = 0 + bisection_log = 0 + while not finished: + if options.bisectChunk: + testsToRun = bisect.pre_test(options, testsToRun, status) + # To inform that we are in the process of bisection, and to + # look for bleedthrough + if options.bisectChunk != "default" and not bisection_log: + self.log.error( + "TEST-UNEXPECTED-FAIL | Bisection | Please ignore repeats " + "and look for 'Bleedthrough' (if any) at the end of " + "the failure list" + ) + bisection_log = 1 + + result = self.doTests(options, testsToRun, manifestToFilter) + if result == TBPL_RETRY: # terminate task + return result + + if options.bisectChunk: + status = bisect.post_test(options, self.expectedError, self.result) + else: + status = -1 + + if status == -1: + finished = True + + # We need to print the summary only if options.bisectChunk has a value. + # Also we need to make sure that we do not print the summary in between + # running tests via --run-by-dir. + if options.bisectChunk and options.bisectChunk in self.result: + bisect.print_summary() + + return result + + def groupTestsByScheme(self, tests): + """ + split tests into groups by schemes. test is classified as http if + no scheme specified + """ + httpTests = [] + httpsTests = [] + for test in tests: + if not test.get("scheme") or test.get("scheme") == "http": + httpTests.append(test) + elif test.get("scheme") == "https": + httpsTests.append(test) + return {"http": httpTests, "https": httpsTests} + + def verifyTests(self, options): + """ + Support --verify mode: Run test(s) many times in a variety of + configurations/environments in an effort to find intermittent + failures. + """ + + # Number of times to repeat test(s) when running with --repeat + VERIFY_REPEAT = 10 + # Number of times to repeat test(s) when running test in + VERIFY_REPEAT_SINGLE_BROWSER = 5 + + def step1(): + options.repeat = VERIFY_REPEAT + options.keep_open = False + options.runUntilFailure = True + options.profilePath = None + options.comparePrefs = True + result = self.runTests(options) + result = result or (-2 if self.countfail > 0 else 0) + self.message_logger.finish() + return result + + def step2(): + options.repeat = 0 + options.keep_open = False + options.runUntilFailure = False + for i in range(VERIFY_REPEAT_SINGLE_BROWSER): + options.profilePath = None + result = self.runTests(options) + result = result or (-2 if self.countfail > 0 else 0) + self.message_logger.finish() + if result != 0: + break + return result + + def step3(): + options.repeat = VERIFY_REPEAT + options.keep_open = False + options.runUntilFailure = True + options.environment.append("MOZ_CHAOSMODE=0xfb") + options.profilePath = None + result = self.runTests(options) + options.environment.remove("MOZ_CHAOSMODE=0xfb") + result = result or (-2 if self.countfail > 0 else 0) + self.message_logger.finish() + return result + + def step4(): + options.repeat = 0 + options.keep_open = False + options.runUntilFailure = False + options.environment.append("MOZ_CHAOSMODE=0xfb") + for i in range(VERIFY_REPEAT_SINGLE_BROWSER): + options.profilePath = None + result = self.runTests(options) + result = result or (-2 if self.countfail > 0 else 0) + self.message_logger.finish() + if result != 0: + break + options.environment.remove("MOZ_CHAOSMODE=0xfb") + return result + + def fission_step(fission_pref): + if fission_pref not in options.extraPrefs: + options.extraPrefs.append(fission_pref) + options.keep_open = False + options.runUntilFailure = True + options.profilePath = None + result = self.runTests(options) + result = result or (-2 if self.countfail > 0 else 0) + self.message_logger.finish() + return result + + def fission_step1(): + return fission_step("fission.autostart=false") + + def fission_step2(): + return fission_step("fission.autostart=true") + + if options.verify_fission: + steps = [ + ("1. Run each test without fission.", fission_step1), + ("2. Run each test with fission.", fission_step2), + ] + else: + steps = [ + ("1. Run each test %d times in one browser." % VERIFY_REPEAT, step1), + ( + "2. Run each test %d times in a new browser each time." + % VERIFY_REPEAT_SINGLE_BROWSER, + step2, + ), + ( + "3. Run each test %d times in one browser, in chaos mode." + % VERIFY_REPEAT, + step3, + ), + ( + "4. Run each test %d times in a new browser each time, " + "in chaos mode." % VERIFY_REPEAT_SINGLE_BROWSER, + step4, + ), + ] + + stepResults = {} + for (descr, step) in steps: + stepResults[descr] = "not run / incomplete" + + startTime = datetime.now() + maxTime = timedelta(seconds=options.verify_max_time) + finalResult = "PASSED" + for (descr, step) in steps: + if (datetime.now() - startTime) > maxTime: + self.log.info("::: Test verification is taking too long: Giving up!") + self.log.info( + "::: So far, all checks passed, but not all checks were run." + ) + break + self.log.info(":::") + self.log.info('::: Running test verification step "%s"...' % descr) + self.log.info(":::") + result = step() + if result != 0: + stepResults[descr] = "FAIL" + finalResult = "FAILED!" + break + stepResults[descr] = "Pass" + + self.logPreamble([]) + + self.log.info(":::") + self.log.info("::: Test verification summary for:") + self.log.info(":::") + tests = self.getActiveTests(options) + for test in tests: + self.log.info("::: " + test["path"]) + self.log.info(":::") + for descr in sorted(stepResults.keys()): + self.log.info("::: %s : %s" % (descr, stepResults[descr])) + self.log.info(":::") + self.log.info("::: Test verification %s" % finalResult) + self.log.info(":::") + + return 0 + + def runTests(self, options): + """Prepare, configure, run tests and cleanup""" + self.extraPrefs = parse_preferences(options.extraPrefs) + self.extraPrefs["fission.autostart"] = not options.disable_fission + + # for test manifest parsing. + mozinfo.update( + { + "a11y_checks": options.a11y_checks, + "e10s": options.e10s, + "fission": not options.disable_fission, + "headless": options.headless, + "http3": options.useHttp3Server, + # Until the test harness can understand default pref values, + # (https://bugzilla.mozilla.org/show_bug.cgi?id=1577912) this value + # should by synchronized with the default pref value indicated in + # StaticPrefList.yaml. + # + # Currently for automation, the pref defaults to true (but can be + # overridden with --setpref). + "serviceworker_e10s": True, + "sessionHistoryInParent": not options.disable_fission + or not self.extraPrefs.get( + "fission.disableSessionHistoryInParent", + mozinfo.info["os"] == "android", + ), + "socketprocess_e10s": self.extraPrefs.get( + "network.process.enabled", False + ), + "socketprocess_networking": self.extraPrefs.get( + "network.http.network_access_on_socket_process.enabled", False + ), + "swgl": self.extraPrefs.get("gfx.webrender.software", False), + "verify": options.verify, + "verify_fission": options.verify_fission, + "webgl_ipc": self.extraPrefs.get("webgl.out-of-process", False), + "wmfme": ( + self.extraPrefs.get("media.wmf.media-engine.enabled", False) + and self.extraPrefs.get( + "media.wmf.media-engine.channel-decoder.enabled", False + ) + ), + "xorigin": options.xOriginTests, + "condprof": options.conditionedProfile, + "msix": "WindowsApps" in options.app, + } + ) + + if not self.mozinfo_variables_shown: + self.mozinfo_variables_shown = True + self.log.info( + "These variables are available in the mozinfo environment and " + "can be used to skip tests conditionally:" + ) + for info in sorted(mozinfo.info.items(), key=lambda item: item[0]): + self.log.info(" {key}: {value}".format(key=info[0], value=info[1])) + self.setTestRoot(options) + + # Despite our efforts to clean up servers started by this script, in practice + # we still see infrequent cases where a process is orphaned and interferes + # with future tests, typically because the old server is keeping the port in use. + # Try to avoid those failures by checking for and killing servers before + # trying to start new ones. + self.killNamedProc("ssltunnel") + self.killNamedProc("xpcshell") + + if options.cleanupCrashes: + mozcrash.cleanup_pending_crash_reports() + + tests = self.getActiveTests(options) + self.logPreamble(tests) + + if mozinfo.info["fission"] and not mozinfo.info["e10s"]: + # Make sure this is logged *after* suite_start so it gets associated with the + # current suite in the summary formatters. + self.log.error("Fission is not supported without e10s.") + return 1 + + tests = [t for t in tests if "disabled" not in t] + + # Until we have all green, this does not run on a11y (for perf reasons) + if not options.runByManifest: + result = self.runMochitests(options, [t["path"] for t in tests]) + self.handleShutdownProfile(options) + return result + + # code for --run-by-manifest + manifests = set(t["manifest"] for t in tests) + result = 0 + + origPrefs = self.extraPrefs.copy() + for m in sorted(manifests): + self.log.info("Running manifest: {}".format(m)) + + args = list(self.args_by_manifest[m])[0] + self.extraArgs = [] + if args: + for arg in args.strip().split(): + # Split off the argument value if available so that both + # name and value will be set individually + self.extraArgs.extend(arg.split("=")) + + self.log.info( + "The following arguments will be set:\n {}".format( + "\n ".join(self.extraArgs) + ) + ) + + prefs = list(self.prefs_by_manifest[m])[0] + self.extraPrefs = origPrefs.copy() + if prefs: + prefs = prefs.strip().split() + self.log.info( + "The following extra prefs will be set:\n {}".format( + "\n ".join(prefs) + ) + ) + self.extraPrefs.update(parse_preferences(prefs)) + + envVars = list(self.env_vars_by_manifest[m])[0] + self.extraEnv = {} + if envVars: + self.extraEnv = envVars.strip().split() + self.log.info( + "The following extra environment variables will be set:\n {}".format( + "\n ".join(self.extraEnv) + ) + ) + + self.parseAndCreateTestsDirs(m) + + # If we are using --run-by-manifest, we should not use the profile path (if) provided + # by the user, since we need to create a new directory for each run. We would face + # problems if we use the directory provided by the user. + tests_in_manifest = [t["path"] for t in tests if t["manifest"] == m] + res = self.runMochitests(options, tests_in_manifest, manifestToFilter=m) + if res == TBPL_RETRY: # terminate task + return res + result = result or res + + # Dump the logging buffer + self.message_logger.dump_buffered() + + if res == -1: + break + + if self.manifest is not None: + self.cleanup(options, True) + + e10s_mode = "e10s" if options.e10s else "non-e10s" + + # for failure mode: where browser window has crashed and we have no reported results + if ( + self.countpass == self.countfail == self.counttodo == 0 + and options.crashAsPass + ): + self.countpass = 1 + self.result = 0 + + # printing total number of tests + if options.flavor == "browser": + print("TEST-INFO | checking window state") + print("Browser Chrome Test Summary") + print("\tPassed: %s" % self.countpass) + print("\tFailed: %s" % self.countfail) + print("\tTodo: %s" % self.counttodo) + print("\tMode: %s" % e10s_mode) + print("*** End BrowserChrome Test Results ***") + else: + print("0 INFO TEST-START | Shutdown") + print("1 INFO Passed: %s" % self.countpass) + print("2 INFO Failed: %s" % self.countfail) + print("3 INFO Todo: %s" % self.counttodo) + print("4 INFO Mode: %s" % e10s_mode) + print("5 INFO SimpleTest FINISHED") + + self.handleShutdownProfile(options) + + if not result: + if self.countfail or not (self.countpass or self.counttodo): + # at least one test failed, or + # no tests passed, and no tests failed (possibly a crash) + result = 1 + + return result + + def handleShutdownProfile(self, options): + # If shutdown profiling was enabled, then the user will want to access the + # performance profile. The following code will display helpful log messages + # and automatically open the profile if it is requested. + if self.browserEnv and "MOZ_PROFILER_SHUTDOWN" in self.browserEnv: + profile_path = self.browserEnv["MOZ_PROFILER_SHUTDOWN"] + + profiler_logger = get_proxy_logger("profiler") + profiler_logger.info("Shutdown performance profiling was enabled") + profiler_logger.info("Profile saved locally to: %s" % profile_path) + + if options.profilerSaveOnly or options.profiler: + # Only do the extra work of symbolicating and viewing the profile if + # officially requested through a command line flag. The MOZ_PROFILER_* + # flags can be set by a user. + symbolicate_profile_json(profile_path, options.topobjdir) + view_gecko_profile_from_mochitest( + profile_path, options, profiler_logger + ) + else: + profiler_logger.info( + "The profiler was enabled outside of the mochitests. " + "Use --profiler instead of MOZ_PROFILER_SHUTDOWN to " + "symbolicate and open the profile automatically." + ) + + # Clean up the temporary file if it exists. + if self.profiler_tempdir: + shutil.rmtree(self.profiler_tempdir) + + def doTests(self, options, testsToFilter=None, manifestToFilter=None): + # A call to initializeLooping method is required in case of --run-by-dir or --bisect-chunk + # since we need to initialize variables for each loop. + if options.bisectChunk or options.runByManifest: + self.initializeLooping(options) + + # get debugger info, a dict of: + # {'path': path to the debugger (string), + # 'interactive': whether the debugger is interactive or not (bool) + # 'args': arguments to the debugger (list) + # TODO: use mozrunner.local.debugger_arguments: + # https://github.com/mozilla/mozbase/blob/master/mozrunner/mozrunner/local.py#L42 + + debuggerInfo = None + if options.debugger: + debuggerInfo = mozdebug.get_debugger_info( + options.debugger, options.debuggerArgs, options.debuggerInteractive + ) + + if options.useTestMediaDevices: + devices = findTestMediaDevices(self.log) + if not devices: + self.log.error("Could not find test media devices to use") + return 1 + self.mediaDevices = devices + self.initializeVirtualInputDevices() + + # See if we were asked to run on Valgrind + valgrindPath = None + valgrindArgs = None + valgrindSuppFiles = None + if options.valgrind: + valgrindPath = options.valgrind + if options.valgrindArgs: + valgrindArgs = options.valgrindArgs + if options.valgrindSuppFiles: + valgrindSuppFiles = options.valgrindSuppFiles + + if (valgrindArgs or valgrindSuppFiles) and not valgrindPath: + self.log.error( + "Specified --valgrind-args or --valgrind-supp-files," + " but not --valgrind" + ) + return 1 + + if valgrindPath and debuggerInfo: + self.log.error("Can't use both --debugger and --valgrind together") + return 1 + + if valgrindPath and not valgrindSuppFiles: + valgrindSuppFiles = ",".join(get_default_valgrind_suppression_files()) + + # buildProfile sets self.profile . + # This relies on sideeffects and isn't very stateful: + # https://bugzilla.mozilla.org/show_bug.cgi?id=919300 + self.manifest = self.buildProfile(options) + if self.manifest is None: + return 1 + + self.leak_report_file = os.path.join(options.profilePath, "runtests_leaks.log") + + self.browserEnv = self.buildBrowserEnv(options, debuggerInfo is not None) + + if self.browserEnv is None: + return 1 + + if self.mozLogs: + self.browserEnv["MOZ_LOG_FILE"] = "{}/moz-pid=%PID-uid={}.log".format( + self.browserEnv["MOZ_UPLOAD_DIR"], str(uuid.uuid4()) + ) + + status = 0 + try: + self.startServers(options, debuggerInfo) + + if options.jsconsole: + options.browserArgs.extend(["--jsconsole"]) + + if options.jsdebugger: + options.browserArgs.extend(["-wait-for-jsdebugger", "-jsdebugger"]) + + # -jsdebugger takes a binary path as an optional argument. + # Append jsdebuggerPath right after `-jsdebugger`. + if options.jsdebuggerPath: + options.browserArgs.extend([options.jsdebuggerPath]) + + # Remove the leak detection file so it can't "leak" to the tests run. + # The file is not there if leak logging was not enabled in the + # application build. + if os.path.exists(self.leak_report_file): + os.remove(self.leak_report_file) + + # then again to actually run mochitest + if options.timeout: + timeout = options.timeout + 30 + elif options.debugger or not options.autorun: + timeout = None + else: + # We generally want the JS harness or marionette to handle + # timeouts if they can. + # The default JS harness timeout is currently 300 seconds. + # The default Marionette socket timeout is currently 360 seconds. + # Wait a little (10 seconds) more before timing out here. + # See bug 479518 and bug 1414063. + timeout = 370.0 + + if "MOZ_CHAOSMODE=0xfb" in options.environment and timeout: + timeout *= 2 + + # Detect shutdown leaks for m-bc runs if + # code coverage is not enabled. + detectShutdownLeaks = False + if options.jscov_dir_prefix is None: + detectShutdownLeaks = ( + mozinfo.info["debug"] + and options.flavor == "browser" + and options.subsuite != "thunderbird" + and not options.crashAsPass + ) + + self.start_script_kwargs["flavor"] = self.normflavor(options.flavor) + marionette_args = { + "symbols_path": options.symbolsPath, + "socket_timeout": options.marionette_socket_timeout, + "startup_timeout": options.marionette_startup_timeout, + } + + if options.marionette: + host, port = options.marionette.split(":") + marionette_args["host"] = host + marionette_args["port"] = int(port) + + # testsToFilter parameter is used to filter out the test list that + # is sent to getTestsByScheme + for (scheme, tests) in self.getTestsByScheme( + options, testsToFilter, True, manifestToFilter + ): + # read the number of tests here, if we are not going to run any, + # terminate early + if not tests: + continue + + self.currentTests = [t["path"] for t in tests] + testURL = self.buildTestURL(options, scheme=scheme) + + self.buildURLOptions(options, self.browserEnv) + if self.urlOpts: + testURL += "?" + "&".join(self.urlOpts) + + if options.runFailures: + testURL += "&runFailures=true" + + if options.timeoutAsPass: + testURL += "&timeoutAsPass=true" + + if options.conditionedProfile: + testURL += "&conditionedProfile=true" + + self.log.info("runtests.py | Running with scheme: {}".format(scheme)) + self.log.info( + "runtests.py | Running with e10s: {}".format(options.e10s) + ) + self.log.info( + "runtests.py | Running with fission: {}".format( + mozinfo.info.get("fission", True) + ) + ) + self.log.info( + "runtests.py | Running with cross-origin iframes: {}".format( + mozinfo.info.get("xorigin", False) + ) + ) + self.log.info( + "runtests.py | Running with serviceworker_e10s: {}".format( + mozinfo.info.get("serviceworker_e10s", False) + ) + ) + self.log.info( + "runtests.py | Running with socketprocess_e10s: {}".format( + mozinfo.info.get("socketprocess_e10s", False) + ) + ) + self.log.info("runtests.py | Running tests: start.\n") + ret, _ = self.runApp( + testURL, + self.browserEnv, + options.app, + profile=self.profile, + extraArgs=options.browserArgs + self.extraArgs, + utilityPath=options.utilityPath, + debuggerInfo=debuggerInfo, + valgrindPath=valgrindPath, + valgrindArgs=valgrindArgs, + valgrindSuppFiles=valgrindSuppFiles, + symbolsPath=options.symbolsPath, + timeout=timeout, + detectShutdownLeaks=detectShutdownLeaks, + screenshotOnFail=options.screenshotOnFail, + bisectChunk=options.bisectChunk, + marionette_args=marionette_args, + e10s=options.e10s, + runFailures=options.runFailures, + crashAsPass=options.crashAsPass, + currentManifest=manifestToFilter, + ) + status = ret or status + except KeyboardInterrupt: + self.log.info("runtests.py | Received keyboard interrupt.\n") + status = -1 + except Exception as e: + traceback.print_exc() + self.log.error( + "Automation Error: Received unexpected exception while running application\n" + ) + if "ADBTimeoutError" in repr(e): + self.log.info("runtests.py | Device disconnected. Aborting test.\n") + raise + status = 1 + finally: + self.stopServers() + + ignoreMissingLeaks = options.ignoreMissingLeaks + leakThresholds = options.leakThresholds + + if options.crashAsPass: + ignoreMissingLeaks.append("tab") + ignoreMissingLeaks.append("socket") + + # Provide a floor for Windows chrome leak detection, because we know + # we have some Windows-specific shutdown hangs that we avoid by timing + # out and leaking memory. + if options.flavor == "chrome" and mozinfo.isWin: + leakThresholds["default"] += 1296 + + # Stop leak detection if m-bc code coverage is enabled + # by maxing out the leak threshold for all processes. + if options.jscov_dir_prefix: + for processType in leakThresholds: + ignoreMissingLeaks.append(processType) + leakThresholds[processType] = sys.maxsize + + utilityPath = options.utilityPath or options.xrePath + if status == 0: + # ignore leak checks for crashes + mozleak.process_leak_log( + self.leak_report_file, + leak_thresholds=leakThresholds, + ignore_missing_leaks=ignoreMissingLeaks, + log=self.log, + stack_fixer=get_stack_fixer_function(utilityPath, options.symbolsPath), + ) + + self.log.info("runtests.py | Running tests: end.") + + if self.manifest is not None: + self.cleanup(options, False) + + return status + + def handleTimeout( + self, timeout, proc, utilityPath, debuggerInfo, browser_pid, processLog + ): + """handle process output timeout""" + # TODO: bug 913975 : _processOutput should call self.processOutputLine + # one more time one timeout (I think) + error_message = ( + "TEST-UNEXPECTED-TIMEOUT | %s | application timed out after " + "%d seconds with no output" + ) % (self.lastTestSeen, int(timeout)) + self.message_logger.dump_buffered() + self.message_logger.buffering = False + self.log.info(error_message) + self.log.warning("Force-terminating active process(es).") + + browser_pid = browser_pid or proc.pid + child_pids = self.extract_child_pids(processLog, browser_pid) + self.log.info("Found child pids: %s" % child_pids) + + if HAVE_PSUTIL: + try: + browser_proc = [psutil.Process(browser_pid)] + except Exception: + self.log.info("Failed to get proc for pid %d" % browser_pid) + browser_proc = [] + try: + child_procs = [psutil.Process(pid) for pid in child_pids] + except Exception: + self.log.info("Failed to get child procs") + child_procs = [] + for pid in child_pids: + self.killAndGetStack( + pid, utilityPath, debuggerInfo, dump_screen=not debuggerInfo + ) + gone, alive = psutil.wait_procs(child_procs, timeout=30) + for p in gone: + self.log.info("psutil found pid %s dead" % p.pid) + for p in alive: + self.log.warning("failed to kill pid %d after 30s" % p.pid) + self.killAndGetStack( + browser_pid, utilityPath, debuggerInfo, dump_screen=not debuggerInfo + ) + gone, alive = psutil.wait_procs(browser_proc, timeout=30) + for p in gone: + self.log.info("psutil found pid %s dead" % p.pid) + for p in alive: + self.log.warning("failed to kill pid %d after 30s" % p.pid) + else: + self.log.error( + "psutil not available! Will wait 30s before " + "attempting to kill parent process. This should " + "not occur in mozilla automation. See bug 1143547." + ) + for pid in child_pids: + self.killAndGetStack( + pid, utilityPath, debuggerInfo, dump_screen=not debuggerInfo + ) + if child_pids: + time.sleep(30) + + self.killAndGetStack( + browser_pid, utilityPath, debuggerInfo, dump_screen=not debuggerInfo + ) + + def archiveMozLogs(self): + if self.mozLogs: + with zipfile.ZipFile( + "{}/mozLogs.zip".format(os.environ["MOZ_UPLOAD_DIR"]), + "w", + zipfile.ZIP_DEFLATED, + ) as logzip: + for logfile in glob.glob( + "{}/moz*.log*".format(os.environ["MOZ_UPLOAD_DIR"]) + ): + logzip.write(logfile, os.path.basename(logfile)) + os.remove(logfile) + logzip.close() + + class OutputHandler(object): + + """line output handler for mozrunner""" + + def __init__( + self, + harness, + utilityPath, + symbolsPath=None, + dump_screen_on_timeout=True, + dump_screen_on_fail=False, + shutdownLeaks=None, + lsanLeaks=None, + bisectChunk=None, + ): + """ + harness -- harness instance + dump_screen_on_timeout -- whether to dump the screen on timeout + """ + self.harness = harness + self.utilityPath = utilityPath + self.symbolsPath = symbolsPath + self.dump_screen_on_timeout = dump_screen_on_timeout + self.dump_screen_on_fail = dump_screen_on_fail + self.shutdownLeaks = shutdownLeaks + self.lsanLeaks = lsanLeaks + self.bisectChunk = bisectChunk + self.browserProcessId = None + self.stackFixerFunction = self.stackFixer() + + def processOutputLine(self, line): + """per line handler of output for mozprocess""" + # Parsing the line (by the structured messages logger). + messages = self.harness.message_logger.parse_line(line) + + for message in messages: + # Passing the message to the handlers + for handler in self.outputHandlers(): + message = handler(message) + + # Processing the message by the logger + self.harness.message_logger.process_message(message) + + __call__ = processOutputLine + + def outputHandlers(self): + """returns ordered list of output handlers""" + handlers = [ + self.fix_stack, + self.record_last_test, + self.dumpScreenOnTimeout, + self.dumpScreenOnFail, + self.trackShutdownLeaks, + self.trackLSANLeaks, + self.countline, + ] + if self.bisectChunk: + handlers.append(self.record_result) + handlers.append(self.first_error) + + return handlers + + def stackFixer(self): + """ + return get_stack_fixer_function, if any, to use on the output lines + """ + return get_stack_fixer_function(self.utilityPath, self.symbolsPath) + + def finish(self): + if self.shutdownLeaks: + self.harness.countfail += self.shutdownLeaks.process() + + if self.lsanLeaks: + self.harness.countfail += self.lsanLeaks.process() + + # output message handlers: + # these take a message and return a message + + def record_result(self, message): + # by default make the result key equal to pass. + if message["action"] == "test_start": + key = message["test"].split("/")[-1].strip() + self.harness.result[key] = "PASS" + elif message["action"] == "test_status": + if "expected" in message: + key = message["test"].split("/")[-1].strip() + self.harness.result[key] = "FAIL" + elif message["status"] == "FAIL": + key = message["test"].split("/")[-1].strip() + self.harness.result[key] = "TODO" + return message + + def first_error(self, message): + if ( + message["action"] == "test_status" + and "expected" in message + and message["status"] == "FAIL" + ): + key = message["test"].split("/")[-1].strip() + if key not in self.harness.expectedError: + self.harness.expectedError[key] = message.get( + "message", message["subtest"] + ).strip() + return message + + def countline(self, message): + if message["action"] == "log": + line = message.get("message", "") + elif message["action"] == "process_output": + line = message.get("data", "") + else: + return message + val = 0 + try: + val = int(line.split(":")[-1].strip()) + except (AttributeError, ValueError): + return message + + if "Passed:" in line: + self.harness.countpass += val + elif "Failed:" in line: + self.harness.countfail += val + elif "Todo:" in line: + self.harness.counttodo += val + return message + + def fix_stack(self, message): + if self.stackFixerFunction: + if message["action"] == "log": + message["message"] = self.stackFixerFunction(message["message"]) + elif message["action"] == "process_output": + message["data"] = self.stackFixerFunction(message["data"]) + return message + + def record_last_test(self, message): + """record last test on harness""" + if message["action"] == "test_start": + self.harness.lastTestSeen = message["test"] + elif message["action"] == "test_end": + self.harness.lastTestSeen = "{} (finished)".format(message["test"]) + return message + + def dumpScreenOnTimeout(self, message): + if ( + not self.dump_screen_on_fail + and self.dump_screen_on_timeout + and message["action"] == "test_status" + and "expected" in message + and "Test timed out" in message["subtest"] + ): + self.harness.dumpScreen(self.utilityPath) + return message + + def dumpScreenOnFail(self, message): + if ( + self.dump_screen_on_fail + and "expected" in message + and message["status"] == "FAIL" + ): + self.harness.dumpScreen(self.utilityPath) + return message + + def trackLSANLeaks(self, message): + if self.lsanLeaks and message["action"] in ("log", "process_output"): + line = ( + message.get("message", "") + if message["action"] == "log" + else message["data"] + ) + self.lsanLeaks.log(line) + return message + + def trackShutdownLeaks(self, message): + if self.shutdownLeaks: + self.shutdownLeaks.log(message) + return message + + +def view_gecko_profile_from_mochitest(profile_path, options, profiler_logger): + """Getting shutdown performance profiles from just the command line arguments is + difficult. This function makes the developer ergonomics a bit easier by taking the + generated Gecko profile, and automatically serving it to profiler.firefox.com. The + Gecko profile during shutdown is dumped to disk at: + + {objdir}/_tests/testing/mochitest/{profilename} + + This function takes that file, and launches a local webserver, and then points + a browser to profiler.firefox.com to view it. From there it's easy to publish + or save the profile. + """ + + if options.profilerSaveOnly: + # The user did not want this to automatically open, only share the location. + return + + if not os.path.exists(profile_path): + profiler_logger.error( + "No profile was found at the profile path, cannot " + "launch profiler.firefox.com." + ) + return + + profiler_logger.info("Loading this profile in the Firefox Profiler") + + view_gecko_profile(profile_path) + + +def run_test_harness(parser, options): + parser.validate(options) + + logger_options = { + key: value + for key, value in six.iteritems(vars(options)) + if key.startswith("log") or key == "valgrind" + } + + runner = MochitestDesktop( + options.flavor, logger_options, options.stagedAddons, quiet=options.quiet + ) + + options.runByManifest = False + if options.flavor in ("plain", "browser", "chrome"): + options.runByManifest = True + + if options.verify or options.verify_fission: + result = runner.verifyTests(options) + else: + result = runner.runTests(options) + + runner.archiveMozLogs() + runner.message_logger.finish() + return result + + +def cli(args=sys.argv[1:]): + # parse command line options + parser = MochitestArgumentParser(app="generic") + options = parser.parse_args(args) + if options is None: + # parsing error + sys.exit(1) + + return run_test_harness(parser, options) + + +if __name__ == "__main__": + sys.exit(cli()) |