diff options
Diffstat (limited to 'python/mozperftest/mozperftest/test/browsertime/runner.py')
-rw-r--r-- | python/mozperftest/mozperftest/test/browsertime/runner.py | 473 |
1 files changed, 473 insertions, 0 deletions
diff --git a/python/mozperftest/mozperftest/test/browsertime/runner.py b/python/mozperftest/mozperftest/test/browsertime/runner.py new file mode 100644 index 0000000000..54a9ace44a --- /dev/null +++ b/python/mozperftest/mozperftest/test/browsertime/runner.py @@ -0,0 +1,473 @@ +# 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 collections +import json +import os +import pathlib +import re +import shutil +import sys +from pathlib import Path + +from mozperftest.test.browsertime.visualtools import get_dependencies, xvfb +from mozperftest.test.noderunner import NodeRunner +from mozperftest.utils import ON_TRY, get_output_dir, install_package + +BROWSERTIME_SRC_ROOT = Path(__file__).parent + + +def matches(args, *flags): + """Returns True if any argument matches any of the given flags + + Maybe with an argument. + """ + + for flag in flags: + if flag in args or any(arg.startswith(flag + "=") for arg in args): + return True + return False + + +def extract_browser_name(args): + "Extracts the browser name if any" + # These are BT arguments, it's BT job to check them + # here we just want to extract the browser name + res = re.findall(r"(--browser|-b)[= ]([\w]+)", " ".join(args)) + if res == []: + return None + return res[0][-1] + + +class NodeException(Exception): + pass + + +class BrowsertimeRunner(NodeRunner): + """Runs a browsertime test.""" + + name = "browsertime" + activated = True + user_exception = True + + arguments = { + "cycles": {"type": int, "default": 1, "help": "Number of full cycles"}, + "iterations": {"type": int, "default": 1, "help": "Number of iterations"}, + "node": {"type": str, "default": None, "help": "Path to Node.js"}, + "geckodriver": {"type": str, "default": None, "help": "Path to geckodriver"}, + "binary": { + "type": str, + "default": None, + "help": "Path to the desktop browser, or Android app name.", + }, + "clobber": { + "action": "store_true", + "default": False, + "help": "Force-update the installation.", + }, + "install-url": { + "type": str, + "default": None, + "help": "Use this URL as the install url.", + }, + "extra-options": { + "type": str, + "default": "", + "help": "Extra options passed to browsertime.js", + }, + "xvfb": {"action": "store_true", "default": False, "help": "Use xvfb"}, + "no-window-recorder": { + "action": "store_true", + "default": False, + "help": "Use the window recorder", + }, + "viewport-size": {"type": str, "default": "1280x1024", "help": "Viewport size"}, + "existing-results": { + "type": str, + "default": None, + "help": "Directory containing existing results to load.", + }, + } + + def __init__(self, env, mach_cmd): + super(BrowsertimeRunner, self).__init__(env, mach_cmd) + self.topsrcdir = mach_cmd.topsrcdir + self._mach_context = mach_cmd._mach_context + self.virtualenv_manager = mach_cmd.virtualenv_manager + self._created_dirs = [] + self._test_script = None + self._setup_helper = None + self.get_binary_path = mach_cmd.get_binary_path + + @property + def setup_helper(self): + if self._setup_helper is not None: + return self._setup_helper + sys.path.append(str(Path(self.topsrcdir, "tools", "lint", "eslint"))) + import setup_helper + + self._setup_helper = setup_helper + return self._setup_helper + + @property + def artifact_cache_path(self): + """Downloaded artifacts will be kept here.""" + # The convention is $MOZBUILD_STATE_PATH/cache/$FEATURE. + return Path(self._mach_context.state_dir, "cache", "browsertime") + + @property + def state_path(self): + """Unpacked artifacts will be kept here.""" + # The convention is $MOZBUILD_STATE_PATH/$FEATURE. + res = Path(self._mach_context.state_dir, "browsertime") + os.makedirs(str(res), exist_ok=True) + return res + + @property + def browsertime_js(self): + root = os.environ.get("BROWSERTIME", self.state_path) + path = Path(root, "node_modules", "browsertime", "bin", "browsertime.js") + if path.exists(): + os.environ["BROWSERTIME_JS"] = str(path) + return path + + @property + def visualmetrics_py(self): + root = os.environ.get("BROWSERTIME", self.state_path) + path = Path( + root, "node_modules", "browsertime", "browsertime", "visualmetrics.py" + ) + if path.exists(): + os.environ["VISUALMETRICS_PY"] = str(path) + return path + + def _get_browsertime_package(self): + with Path( + os.environ.get("BROWSERTIME", self.state_path), + "node_modules", + "browsertime", + "package.json", + ).open() as package: + + return json.load(package) + + def _get_browsertime_resolved(self): + try: + with Path( + os.environ.get("BROWSERTIME", self.state_path), + "node_modules", + ".package-lock.json", + ).open() as package_lock: + return json.load(package_lock)["packages"]["node_modules/browsertime"][ + "resolved" + ] + + except FileNotFoundError: + # Older versions of node/npm add this metadata to package.json + return self._get_browsertime_package().get("_from") + + def _should_install(self): + # If browsertime doesn't exist, install it + if not self.visualmetrics_py.exists() or not self.browsertime_js.exists(): + return True + + # Browsertime exists, check if it's outdated + with Path(BROWSERTIME_SRC_ROOT, "package.json").open() as new: + new_pkg = json.load(new) + + return not self._get_browsertime_resolved().endswith( + new_pkg["devDependencies"]["browsertime"] + ) + + def setup(self): + """Install browsertime and visualmetrics.py prerequisites and the Node.js package.""" + + node = self.get_arg("node") + if node is not None: + os.environ["NODEJS"] = node + + super(BrowsertimeRunner, self).setup() + install_url = self.get_arg("install-url") + + # installing Python deps on the fly + visualmetrics = self.get_arg("visualmetrics", False) + + if visualmetrics: + # installing Python deps on the fly + for dep in get_dependencies(): + install_package(self.virtualenv_manager, dep, ignore_failure=True) + + # check if the browsertime package has been deployed correctly + # for this we just check for the browsertime directory presence + # we also make sure the visual metrics module is there *if* + # we need it + if not self._should_install() and not self.get_arg("clobber"): + return + + # preparing ~/.mozbuild/browsertime + for file in ("package.json", "package-lock.json"): + src = BROWSERTIME_SRC_ROOT / file + target = self.state_path / file + # Overwrite the existing files + shutil.copyfile(str(src), str(target)) + + package_json_path = self.state_path / "package.json" + + if install_url is not None: + self.info( + "Updating browsertime node module version in {package_json_path} " + "to {install_url}", + install_url=install_url, + package_json_path=str(package_json_path), + ) + + expr = r"/tarball/[a-f0-9]{40}$" + if not re.search(expr, install_url): + raise ValueError( + "New upstream URL does not end with {}: '{}'".format( + expr[:-1], install_url + ) + ) + + with package_json_path.open() as f: + existing_body = json.loads( + f.read(), object_pairs_hook=collections.OrderedDict + ) + + existing_body["devDependencies"]["browsertime"] = install_url + updated_body = json.dumps(existing_body) + with package_json_path.open("w") as f: + f.write(updated_body) + + self._setup_node_packages(package_json_path) + + def _setup_node_packages(self, package_json_path): + # Install the browsertime Node.js requirements. + if not self.setup_helper.check_node_executables_valid(): + return + + should_clobber = self.get_arg("clobber") + # To use a custom `geckodriver`, set + # os.environ[b"GECKODRIVER_BASE_URL"] = bytes(url) + # to an endpoint with binaries named like + # https://github.com/sitespeedio/geckodriver/blob/master/install.js#L31. + + if ON_TRY: + os.environ["CHROMEDRIVER_SKIP_DOWNLOAD"] = "true" + os.environ["GECKODRIVER_SKIP_DOWNLOAD"] = "true" + + self.info( + "Installing browsertime node module from {package_json}", + package_json=str(package_json_path), + ) + install_url = self.get_arg("install-url") + + self.setup_helper.package_setup( + str(self.state_path), + "browsertime", + should_update=install_url is not None, + should_clobber=should_clobber, + no_optional=install_url or ON_TRY, + ) + + def extra_default_args(self, args=[]): + # Add Mozilla-specific default arguments. This is tricky because browsertime is quite + # loose about arguments; repeat arguments are generally accepted but then produce + # difficult to interpret type errors. + extra_args = [] + + # Default to Firefox. Override with `-b ...` or `--browser=...`. + if not matches(args, "-b", "--browser"): + extra_args.extend(("-b", "firefox")) + + # Default to not collect HAR. Override with `--skipHar=false`. + if not matches(args, "--har", "--skipHar", "--gzipHar"): + extra_args.append("--skipHar") + + extra_args.extend(["--viewPort", self.get_arg("viewport-size")]) + + if not matches(args, "--android"): + binary = self.get_arg("binary") + if binary is not None: + extra_args.extend(("--firefox.binaryPath", binary)) + else: + # If --firefox.binaryPath is not specified, default to the objdir binary + # Note: --firefox.release is not a real browsertime option, but it will + # silently ignore it instead and default to a release installation. + if ( + not matches( + args, + "--firefox.binaryPath", + "--firefox.release", + "--firefox.nightly", + "--firefox.beta", + "--firefox.developer", + ) + and extract_browser_name(args) != "chrome" + ): + extra_args.extend(("--firefox.binaryPath", self.get_binary_path())) + + geckodriver = self.get_arg("geckodriver") + if geckodriver is not None: + extra_args.extend(("--firefox.geckodriverPath", geckodriver)) + + if extra_args: + self.debug( + "Running browsertime with extra default arguments: {extra_args}", + extra_args=extra_args, + ) + + return extra_args + + def _android_args(self, metadata): + app_name = self.get_arg("android-app-name") + + args_list = [ + "--android", + "--firefox.android.package", + app_name, + ] + activity = self.get_arg("android-activity") + if activity is not None: + args_list += ["--firefox.android.activity", activity] + + return args_list + + def _line_handler(self, line): + line_matcher = re.compile(r"(\[\d{4}-\d{2}-\d{2}.*\])\s+([a-zA-Z]+):\s+(.*)") + match = line_matcher.match(line) + if not match: + return + + date, level, msg = match.groups() + msg = msg.replace("{", "{{").replace("}", "}}") + level = level.lower() + if "error" in level: + self.error("Mozperftest failed to run: {}".format(msg), msg) + elif "warning" in level: + self.warning(msg) + else: + self.info(msg) + + def run(self, metadata): + self._test_script = metadata.script + self.setup() + + existing = self.get_arg("browsertime-existing-results") + if existing: + metadata.add_result( + {"results": existing, "name": self._test_script["name"]} + ) + return metadata + + cycles = self.get_arg("cycles", 1) + for cycle in range(1, cycles + 1): + + # Build an output directory + output = self.get_arg("output") + if output is None: + output = pathlib.Path(self.topsrcdir, "artifacts") + result_dir = get_output_dir(output, f"browsertime-results-{cycle}") + + # Run the test cycle + metadata.run_hook( + "before_cycle", metadata, self.env, cycle, self._test_script + ) + try: + metadata = self._one_cycle(metadata, result_dir) + finally: + metadata.run_hook( + "after_cycle", metadata, self.env, cycle, self._test_script + ) + return metadata + + def _one_cycle(self, metadata, result_dir): + profile = self.get_arg("profile-directory") + is_login_site = False + + args = [ + "--resultDir", + str(result_dir), + "--firefox.profileTemplate", + profile, + "--iterations", + str(self.get_arg("iterations")), + self._test_script["filename"], + ] + + # Set *all* prefs found in browser_prefs because + # browsertime will override the ones found in firefox.profileTemplate + # with its own defaults at `firefoxPreferences.js` + # Using `--firefox.preference` ensures we override them. + # see https://github.com/sitespeedio/browsertime/issues/1427 + browser_prefs = metadata.get_options("browser_prefs") + for key, value in browser_prefs.items(): + args += ["--firefox.preference", f"{key}:{value}"] + + if self.get_arg("verbose"): + args += ["-vvv"] + + # if the visualmetrics layer is activated, we want to feed it + visualmetrics = self.get_arg("visualmetrics", False) + if visualmetrics: + args += ["--video", "true"] + if not self.get_arg("no-window-recorder"): + args += ["--firefox.windowRecorder", "true"] + + extra_options = self.get_arg("extra-options") + if extra_options: + for option in extra_options.split(","): + option = option.strip() + if not option: + continue + option = option.split("=", 1) + if len(option) != 2: + self.warning( + f"Skipping browsertime option {option} as it " + "is missing a name/value pairing. We expect options " + "to be formatted as: --browsertime-extra-options " + "'browserRestartTries=1,timeouts.browserStart=10'" + ) + continue + name, value = option + + # Check if we have a login site + if name == "browsertime.login" and value: + is_login_site = True + + self.info(f"Adding extra browsertime argument: --{name} {value}") + args += ["--" + name, value] + + if self.get_arg("android"): + args.extend(self._android_args(metadata)) + + # Remove any possible verbose option if we are on Try and using logins + if is_login_site and ON_TRY: + self.info("Turning off verbose mode for login-logic") + self.info( + "Please contact the perftest team if you need verbose mode enabled." + ) + for verbose_level in ("-v", "-vv", "-vvv", "-vvvv"): + try: + args.remove(verbose_level) + except ValueError: + pass + + extra = self.extra_default_args(args=args) + command = [str(self.browsertime_js)] + extra + args + self.info("Running browsertime with this command %s" % " ".join(command)) + + if visualmetrics and self.get_arg("xvfb"): + with xvfb(): + exit_code = self.node(command, self._line_handler) + else: + exit_code = self.node(command, self._line_handler) + + if exit_code != 0: + raise NodeException(exit_code) + + metadata.add_result( + {"results": str(result_dir), "name": self._test_script["name"]} + ) + + return metadata |