gecko-dev/remote/mach_commands.py

767 lines
23 KiB
Python

# This Source Code Form is subject to the terms of the Mozilla Public
# License, v. 2.0. If a copy of the MPL was not distributed with this
# file, # You can obtain one at http://mozilla.org/MPL/2.0/.
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 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 list(self.expected)
if expectation["testIdPattern"] == expected_name
),
None,
)
if expected_item is None:
# if there is no expectation data for a specific test case,
# try to find data for a whole file.
expected_item_for_file = next(
(
expectation
for expectation in list(self.expected)
if expectation["testIdPattern"] == f"[{test_file_name}]"
),
None,
)
if expected_item_for_file is None:
# if there is no expectation data for the file,
# try to find data for all tests.
expected_item_for_all_tests = next(
(
expectation
for expectation in list(self.expected)
if expectation["testIdPattern"] == ""
),
None,
)
if expected_item_for_all_tests is None:
expected = ["PASS"]
else:
expected = expected_item_for_all_tests["expectations"]
else:
expected = expected_item_for_file["expectations"]
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 = "webDriverBiDi" in parameters
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)