summaryrefslogtreecommitdiffstats
path: root/remote/mach_commands.py
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--remote/mach_commands.py749
1 files changed, 749 insertions, 0 deletions
diff --git a/remote/mach_commands.py b/remote/mach_commands.py
new file mode 100644
index 0000000000..35b31a2776
--- /dev/null
+++ b/remote/mach_commands.py
@@ -0,0 +1,749 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, # You can obtain one at http://mozilla.org/MPL/2.0/.
+
+import argparse
+import json
+import os
+import platform
+import re
+import shutil
+import subprocess
+import sys
+import tempfile
+from collections import OrderedDict
+
+import mozlog
+import mozprofile
+from mach.decorators import Command, CommandArgument, SubCommand
+from mozbuild import nodeutil
+from mozbuild.base import BinaryNotFoundException, MozbuildObject
+
+EX_CONFIG = 78
+EX_SOFTWARE = 70
+EX_USAGE = 64
+
+
+def setup():
+ # add node and npm from mozbuild to front of system path
+ npm, _ = nodeutil.find_npm_executable()
+ if not npm:
+ exit(EX_CONFIG, "could not find npm executable")
+ path = os.path.abspath(os.path.join(npm, os.pardir))
+ os.environ["PATH"] = "{}{}{}".format(path, os.pathsep, os.environ["PATH"])
+
+
+def remotedir(command_context):
+ return os.path.join(command_context.topsrcdir, "remote")
+
+
+@Command("remote", category="misc", description="Remote protocol related operations.")
+def remote(command_context):
+ """The remote subcommands all relate to the remote protocol."""
+ command_context._sub_mach(["help", "remote"])
+ return 1
+
+
+@SubCommand(
+ "remote", "vendor-puppeteer", "Pull in latest changes of the Puppeteer client."
+)
+@CommandArgument(
+ "--repository",
+ metavar="REPO",
+ default="https://github.com/puppeteer/puppeteer.git",
+ help="The (possibly local) repository to clone from.",
+)
+@CommandArgument(
+ "--commitish",
+ metavar="COMMITISH",
+ required=True,
+ help="The commit or tag object name to check out.",
+)
+@CommandArgument(
+ "--no-install",
+ dest="install",
+ action="store_false",
+ default=True,
+ help="Do not install the just-pulled Puppeteer package,",
+)
+def vendor_puppeteer(command_context, repository, commitish, install):
+ puppeteer_dir = os.path.join(remotedir(command_context), "test", "puppeteer")
+
+ # Preserve our custom mocha reporter
+ shutil.move(
+ os.path.join(puppeteer_dir, "json-mocha-reporter.js"),
+ remotedir(command_context),
+ )
+ shutil.rmtree(puppeteer_dir, ignore_errors=True)
+ os.makedirs(puppeteer_dir)
+ with TemporaryDirectory() as tmpdir:
+ git("clone", "-q", repository, tmpdir)
+ git("checkout", commitish, worktree=tmpdir)
+ git(
+ "checkout-index",
+ "-a",
+ "-f",
+ "--prefix",
+ "{}/".format(puppeteer_dir),
+ worktree=tmpdir,
+ )
+
+ # remove files which may interfere with git checkout of central
+ try:
+ os.remove(os.path.join(puppeteer_dir, ".gitattributes"))
+ os.remove(os.path.join(puppeteer_dir, ".gitignore"))
+ except OSError:
+ pass
+
+ unwanted_dirs = ["experimental", "docs"]
+
+ for dir in unwanted_dirs:
+ dir_path = os.path.join(puppeteer_dir, dir)
+ if os.path.isdir(dir_path):
+ shutil.rmtree(dir_path)
+
+ shutil.move(
+ os.path.join(remotedir(command_context), "json-mocha-reporter.js"),
+ puppeteer_dir,
+ )
+
+ import yaml
+
+ annotation = {
+ "schema": 1,
+ "bugzilla": {
+ "product": "Remote Protocol",
+ "component": "Agent",
+ },
+ "origin": {
+ "name": "puppeteer",
+ "description": "Headless Chrome Node API",
+ "url": repository,
+ "license": "Apache-2.0",
+ "release": commitish,
+ },
+ }
+ with open(os.path.join(puppeteer_dir, "moz.yaml"), "w") as fh:
+ yaml.safe_dump(
+ annotation,
+ fh,
+ default_flow_style=False,
+ encoding="utf-8",
+ allow_unicode=True,
+ )
+
+ if install:
+ env = {"HUSKY": "0", "PUPPETEER_SKIP_DOWNLOAD": "1"}
+ npm(
+ "install",
+ cwd=os.path.join(command_context.topsrcdir, puppeteer_dir),
+ env=env,
+ )
+
+
+def git(*args, **kwargs):
+ cmd = ("git",)
+ if kwargs.get("worktree"):
+ cmd += ("-C", kwargs["worktree"])
+ cmd += args
+
+ pipe = kwargs.get("pipe")
+ git_p = subprocess.Popen(
+ cmd,
+ env={"GIT_CONFIG_NOSYSTEM": "1"},
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE,
+ )
+ pipe_p = None
+ if pipe:
+ pipe_p = subprocess.Popen(pipe, stdin=git_p.stdout, stderr=subprocess.PIPE)
+
+ if pipe:
+ _, pipe_err = pipe_p.communicate()
+ out, git_err = git_p.communicate()
+
+ # use error from first program that failed
+ if git_p.returncode > 0:
+ exit(EX_SOFTWARE, git_err)
+ if pipe and pipe_p.returncode > 0:
+ exit(EX_SOFTWARE, pipe_err)
+
+ return out
+
+
+def npm(*args, **kwargs):
+ from mozprocess import processhandler
+
+ env = None
+ npm, _ = nodeutil.find_npm_executable()
+ if kwargs.get("env"):
+ env = os.environ.copy()
+ env.update(kwargs["env"])
+
+ proc_kwargs = {}
+ if "processOutputLine" in kwargs:
+ proc_kwargs["processOutputLine"] = kwargs["processOutputLine"]
+
+ p = processhandler.ProcessHandler(
+ cmd=npm,
+ args=list(args),
+ cwd=kwargs.get("cwd"),
+ env=env,
+ universal_newlines=True,
+ **proc_kwargs,
+ )
+ if not kwargs.get("wait", True):
+ return p
+
+ wait_proc(p, cmd=npm, exit_on_fail=kwargs.get("exit_on_fail", True))
+
+ return p.returncode
+
+
+def wait_proc(p, cmd=None, exit_on_fail=True, output_timeout=None):
+ try:
+ p.run(outputTimeout=output_timeout)
+ p.wait()
+ if p.timedOut:
+ # In some cases, we wait longer for a mocha timeout
+ print("Timed out after {} seconds of no output".format(output_timeout))
+ finally:
+ p.kill()
+ if exit_on_fail and p.returncode > 0:
+ msg = (
+ "%s: exit code %s" % (cmd, p.returncode)
+ if cmd
+ else "exit code %s" % p.returncode
+ )
+ exit(p.returncode, msg)
+
+
+class MochaOutputHandler(object):
+ def __init__(self, logger, expected):
+ self.hook_re = re.compile('"before\b?.*" hook|"after\b?.*" hook')
+
+ self.logger = logger
+ self.proc = None
+ self.test_results = OrderedDict()
+ self.expected = expected
+ self.unexpected_skips = set()
+
+ self.has_unexpected = False
+ self.logger.suite_start([], name="puppeteer-tests")
+ self.status_map = {
+ "CRASHED": "CRASH",
+ "OK": "PASS",
+ "TERMINATED": "CRASH",
+ "pass": "PASS",
+ "fail": "FAIL",
+ "pending": "SKIP",
+ }
+
+ @property
+ def pid(self):
+ return self.proc and self.proc.pid
+
+ def __call__(self, line):
+ event = None
+ try:
+ if line.startswith("[") and line.endswith("]"):
+ event = json.loads(line)
+ self.process_event(event)
+ except ValueError:
+ pass
+ finally:
+ self.logger.process_output(self.pid, line, command="npm")
+
+ def testExpectation(self, testIdPattern, expected_name):
+ if testIdPattern.find("*") == -1:
+ return expected_name == testIdPattern
+ else:
+ return re.compile(re.escape(testIdPattern).replace("\*", ".*")).search(
+ expected_name
+ )
+
+ def process_event(self, event):
+ if isinstance(event, list) and len(event) > 1:
+ status = self.status_map.get(event[0])
+ test_start = event[0] == "test-start"
+ if not status and not test_start:
+ return
+ test_info = event[1]
+ test_full_title = test_info.get("fullTitle", "")
+ test_name = test_full_title
+ test_path = test_info.get("file", "")
+ test_file_name = os.path.basename(test_path).replace(".js", "")
+ test_err = test_info.get("err")
+ if status == "FAIL" and test_err:
+ if "timeout" in test_err.lower():
+ status = "TIMEOUT"
+ if test_name and test_path:
+ test_name = "{} ({})".format(test_name, os.path.basename(test_path))
+ # mocha hook failures are not tracked in metadata
+ if status != "PASS" and self.hook_re.search(test_name):
+ self.logger.error("TEST-UNEXPECTED-ERROR %s" % (test_name,))
+ return
+ if test_start:
+ self.logger.test_start(test_name)
+ return
+ expected_name = "[{}] {}".format(test_file_name, test_full_title)
+ expected_item = next(
+ (
+ expectation
+ for expectation in reversed(list(self.expected))
+ if self.testExpectation(expectation["testIdPattern"], expected_name)
+ ),
+ None,
+ )
+ if expected_item is None:
+ expected = ["PASS"]
+ else:
+ expected = expected_item["expectations"]
+ # mozlog doesn't really allow unexpected skip,
+ # so if a test is disabled just expect that and note the unexpected skip
+ # Also, mocha doesn't log test-start for skipped tests
+ if status == "SKIP":
+ self.logger.test_start(test_name)
+ if self.expected and status not in expected:
+ self.unexpected_skips.add(test_name)
+ expected = ["SKIP"]
+ known_intermittent = expected[1:]
+ expected_status = expected[0]
+
+ # check if we've seen a result for this test before this log line
+ result_recorded = self.test_results.get(test_name)
+ if result_recorded:
+ self.logger.warning(
+ "Received a second status for {}: "
+ "first {}, now {}".format(test_name, result_recorded, status)
+ )
+ # mocha intermittently logs an additional test result after the
+ # test has already timed out. Avoid recording this second status.
+ if result_recorded != "TIMEOUT":
+ self.test_results[test_name] = status
+ if status not in expected:
+ self.has_unexpected = True
+ self.logger.test_end(
+ test_name,
+ status=status,
+ expected=expected_status,
+ known_intermittent=known_intermittent,
+ )
+
+ def after_end(self):
+ if self.unexpected_skips:
+ self.has_unexpected = True
+ for test_name in self.unexpected_skips:
+ self.logger.error(
+ "TEST-UNEXPECTED-MISSING Unexpected skipped %s" % (test_name,)
+ )
+ self.logger.suite_end()
+
+
+# tempfile.TemporaryDirectory missing from Python 2.7
+class TemporaryDirectory(object):
+ def __init__(self):
+ self.path = tempfile.mkdtemp()
+ self._closed = False
+
+ def __repr__(self):
+ return "<{} {!r}>".format(self.__class__.__name__, self.path)
+
+ def __enter__(self):
+ return self.path
+
+ def __exit__(self, exc, value, tb):
+ self.clean()
+
+ def __del__(self):
+ self.clean()
+
+ def clean(self):
+ if self.path and not self._closed:
+ shutil.rmtree(self.path)
+ self._closed = True
+
+
+class PuppeteerRunner(MozbuildObject):
+ def __init__(self, *args, **kwargs):
+ super(PuppeteerRunner, self).__init__(*args, **kwargs)
+
+ self.remotedir = os.path.join(self.topsrcdir, "remote")
+ self.puppeteer_dir = os.path.join(self.remotedir, "test", "puppeteer")
+
+ def run_test(self, logger, *tests, **params):
+ """
+ Runs Puppeteer unit tests with npm.
+
+ Possible optional test parameters:
+
+ `bidi`:
+ Boolean to indicate whether to test Firefox with BiDi protocol.
+ `binary`:
+ Path for the browser binary to use. Defaults to the local
+ build.
+ `headless`:
+ Boolean to indicate whether to activate Firefox' headless mode.
+ `extra_prefs`:
+ Dictionary of extra preferences to write to the profile,
+ before invoking npm. Overrides default preferences.
+ `enable_webrender`:
+ Boolean to indicate whether to enable WebRender compositor in Gecko.
+ """
+ setup()
+
+ with_bidi = params.get("bidi", False)
+ binary = params.get("binary") or self.get_binary_path()
+ product = params.get("product", "firefox")
+
+ env = {
+ # Print browser process ouptut
+ "DUMPIO": "1",
+ # Checked by Puppeteer's custom mocha config
+ "CI": "1",
+ # Causes some tests to be skipped due to assumptions about install
+ "PUPPETEER_ALT_INSTALL": "1",
+ }
+ extra_options = {}
+ for k, v in params.get("extra_launcher_options", {}).items():
+ extra_options[k] = json.loads(v)
+
+ # Override upstream defaults: no retries, shorter timeout
+ mocha_options = [
+ "--reporter",
+ "./json-mocha-reporter.js",
+ "--retries",
+ "0",
+ "--fullTrace",
+ "--timeout",
+ "20000",
+ "--no-parallel",
+ "--no-coverage",
+ ]
+ env["HEADLESS"] = str(params.get("headless", False))
+ test_command = "test:" + product
+
+ if product == "firefox":
+ env["BINARY"] = binary
+ env["PUPPETEER_PRODUCT"] = "firefox"
+ env["MOZ_WEBRENDER"] = "%d" % params.get("enable_webrender", False)
+ else:
+ env["PUPPETEER_CACHE_DIR"] = os.path.join(
+ self.topobjdir,
+ "_tests",
+ "remote",
+ "test",
+ "puppeteer",
+ ".cache",
+ )
+
+ if with_bidi is True:
+ test_command = test_command + ":bidi"
+ elif env["HEADLESS"] == "True":
+ test_command = test_command + ":headless"
+ else:
+ test_command = test_command + ":headful"
+
+ command = ["run", test_command, "--"] + mocha_options
+
+ prefs = {}
+ for k, v in params.get("extra_prefs", {}).items():
+ print("Using extra preference: {}={}".format(k, v))
+ prefs[k] = mozprofile.Preferences.cast(v)
+
+ if prefs:
+ extra_options["extraPrefsFirefox"] = prefs
+
+ if extra_options:
+ env["EXTRA_LAUNCH_OPTIONS"] = json.dumps(extra_options)
+
+ expected_path = os.path.join(
+ os.path.dirname(__file__),
+ "test",
+ "puppeteer",
+ "test",
+ "TestExpectations.json",
+ )
+ if os.path.exists(expected_path):
+ with open(expected_path) as f:
+ expected_data = json.load(f)
+ else:
+ expected_data = []
+
+ expected_platform = platform.uname().system.lower()
+ if expected_platform == "windows":
+ expected_platform = "win32"
+
+ # Filter expectation data for the selected browser,
+ # headless or headful mode, the operating system,
+ # run in BiDi mode or not.
+ expectations = [
+ expectation
+ for expectation in expected_data
+ if is_relevant_expectation(
+ expectation, product, with_bidi, env["HEADLESS"], expected_platform
+ )
+ ]
+
+ output_handler = MochaOutputHandler(logger, expectations)
+ proc = npm(
+ *command,
+ cwd=self.puppeteer_dir,
+ env=env,
+ processOutputLine=output_handler,
+ wait=False,
+ )
+ output_handler.proc = proc
+
+ # Puppeteer unit tests don't always clean-up child processes in case of
+ # failure, so use an output_timeout as a fallback
+ wait_proc(proc, "npm", output_timeout=60, exit_on_fail=False)
+
+ output_handler.after_end()
+
+ # Non-zero return codes are non-fatal for now since we have some
+ # issues with unresolved promises that shouldn't otherwise block
+ # running the tests
+ if proc.returncode != 0:
+ logger.warning("npm exited with code %s" % proc.returncode)
+
+ if output_handler.has_unexpected:
+ exit(1, "Got unexpected results")
+
+
+def create_parser_puppeteer():
+ p = argparse.ArgumentParser()
+ p.add_argument(
+ "--product", type=str, default="firefox", choices=["chrome", "firefox"]
+ )
+ p.add_argument(
+ "--bidi",
+ action="store_true",
+ help="Flag that indicates whether to test Firefox with BiDi protocol.",
+ )
+ p.add_argument(
+ "--binary",
+ type=str,
+ help="Path to browser binary. Defaults to local Firefox build.",
+ )
+ p.add_argument(
+ "--ci",
+ action="store_true",
+ help="Flag that indicates that tests run in a CI environment.",
+ )
+ p.add_argument(
+ "--disable-fission",
+ action="store_true",
+ default=False,
+ dest="disable_fission",
+ help="Disable Fission (site isolation) in Gecko.",
+ )
+ p.add_argument(
+ "--enable-webrender",
+ action="store_true",
+ help="Enable the WebRender compositor in Gecko.",
+ )
+ p.add_argument(
+ "-z", "--headless", action="store_true", help="Run browser in headless mode."
+ )
+ p.add_argument(
+ "--setpref",
+ action="append",
+ dest="extra_prefs",
+ metavar="<pref>=<value>",
+ help="Defines additional user preferences.",
+ )
+ p.add_argument(
+ "--setopt",
+ action="append",
+ dest="extra_options",
+ metavar="<option>=<value>",
+ help="Defines additional options for `puppeteer.launch`.",
+ )
+ p.add_argument(
+ "-v",
+ dest="verbosity",
+ action="count",
+ default=0,
+ help="Increase remote agent logging verbosity to include "
+ "debug level messages with -v, trace messages with -vv,"
+ "and to not truncate long trace messages with -vvv",
+ )
+ p.add_argument("tests", nargs="*")
+ mozlog.commandline.add_logging_group(p)
+ return p
+
+
+def is_relevant_expectation(
+ expectation, expected_product, with_bidi, is_headless, expected_platform
+):
+ parameters = expectation["parameters"]
+
+ if expected_product == "firefox":
+ is_expected_product = "chrome" not in parameters
+ else:
+ is_expected_product = "firefox" not in parameters
+
+ if with_bidi is True:
+ is_expected_protocol = "cdp" not in parameters
+ is_headless = "True"
+ else:
+ is_expected_protocol = "webDriverBiDi" not in parameters
+
+ if is_headless == "True":
+ is_expected_mode = "headful" not in parameters
+ else:
+ is_expected_mode = "headless" not in parameters
+
+ is_expected_platform = expected_platform in expectation["platforms"]
+
+ return (
+ is_expected_product
+ and is_expected_protocol
+ and is_expected_mode
+ and is_expected_platform
+ )
+
+
+@Command(
+ "puppeteer-test",
+ category="testing",
+ description="Run Puppeteer unit tests.",
+ parser=create_parser_puppeteer,
+)
+@CommandArgument(
+ "--no-install",
+ dest="install",
+ action="store_false",
+ default=True,
+ help="Do not install the Puppeteer package",
+)
+def puppeteer_test(
+ command_context,
+ bidi=None,
+ binary=None,
+ ci=False,
+ disable_fission=False,
+ enable_webrender=False,
+ headless=False,
+ extra_prefs=None,
+ extra_options=None,
+ install=False,
+ verbosity=0,
+ tests=None,
+ product="firefox",
+ **kwargs,
+):
+
+ logger = mozlog.commandline.setup_logging(
+ "puppeteer-test", kwargs, {"mach": sys.stdout}
+ )
+
+ # moztest calls this programmatically with test objects or manifests
+ if "test_objects" in kwargs and tests is not None:
+ logger.error("Expected either 'test_objects' or 'tests'")
+ exit(1)
+
+ if product != "firefox" and extra_prefs is not None:
+ logger.error("User preferences are not recognized by %s" % product)
+ exit(1)
+
+ if "test_objects" in kwargs:
+ tests = []
+ for test in kwargs["test_objects"]:
+ tests.append(test["path"])
+
+ prefs = {}
+ for s in extra_prefs or []:
+ kv = s.split("=")
+ if len(kv) != 2:
+ logger.error("syntax error in --setpref={}".format(s))
+ exit(EX_USAGE)
+ prefs[kv[0]] = kv[1].strip()
+
+ options = {}
+ for s in extra_options or []:
+ kv = s.split("=")
+ if len(kv) != 2:
+ logger.error("syntax error in --setopt={}".format(s))
+ exit(EX_USAGE)
+ options[kv[0]] = kv[1].strip()
+
+ prefs.update({"fission.autostart": True})
+ if disable_fission:
+ prefs.update({"fission.autostart": False})
+
+ if verbosity == 1:
+ prefs["remote.log.level"] = "Debug"
+ elif verbosity > 1:
+ prefs["remote.log.level"] = "Trace"
+ if verbosity > 2:
+ prefs["remote.log.truncate"] = False
+
+ if install:
+ install_puppeteer(command_context, product, ci)
+
+ params = {
+ "bidi": bidi,
+ "binary": binary,
+ "headless": headless,
+ "enable_webrender": enable_webrender,
+ "extra_prefs": prefs,
+ "product": product,
+ "extra_launcher_options": options,
+ }
+ puppeteer = command_context._spawn(PuppeteerRunner)
+ try:
+ return puppeteer.run_test(logger, *tests, **params)
+ except BinaryNotFoundException as e:
+ logger.error(e)
+ logger.info(e.help())
+ exit(1)
+ except Exception as e:
+ exit(EX_SOFTWARE, e)
+
+
+def install_puppeteer(command_context, product, ci):
+ setup()
+ env = {"HUSKY": "0"}
+
+ puppeteer_dir = os.path.join("remote", "test", "puppeteer")
+ puppeteer_dir_full_path = os.path.join(command_context.topsrcdir, puppeteer_dir)
+ puppeteer_test_dir = os.path.join(puppeteer_dir, "test")
+
+ if product == "chrome":
+ env["PUPPETEER_CACHE_DIR"] = os.path.join(
+ command_context.topobjdir, "_tests", puppeteer_dir, ".cache"
+ )
+ else:
+ env["PUPPETEER_SKIP_DOWNLOAD"] = "1"
+
+ if not ci:
+ npm(
+ "run",
+ "clean",
+ cwd=puppeteer_dir_full_path,
+ env=env,
+ exit_on_fail=False,
+ )
+
+ command = "ci" if ci else "install"
+ npm(command, cwd=puppeteer_dir_full_path, env=env)
+ npm(
+ "run",
+ "build",
+ cwd=os.path.join(command_context.topsrcdir, puppeteer_test_dir),
+ env=env,
+ )
+
+
+def exit(code, error=None):
+ if error is not None:
+ if isinstance(error, Exception):
+ import traceback
+
+ traceback.print_exc()
+ else:
+ message = str(error).split("\n")[0].strip()
+ print("{}: {}".format(sys.argv[0], message), file=sys.stderr)
+ sys.exit(code)