summaryrefslogtreecommitdiffstats
path: root/tools/browsertime/mach_commands.py
diff options
context:
space:
mode:
Diffstat (limited to 'tools/browsertime/mach_commands.py')
-rw-r--r--tools/browsertime/mach_commands.py640
1 files changed, 640 insertions, 0 deletions
diff --git a/tools/browsertime/mach_commands.py b/tools/browsertime/mach_commands.py
new file mode 100644
index 0000000000..489d87c13b
--- /dev/null
+++ b/tools/browsertime/mach_commands.py
@@ -0,0 +1,640 @@
+# 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/.
+
+r"""Make it easy to install and run [browsertime](https://github.com/sitespeedio/browsertime).
+
+Browsertime is a harness for running performance tests, similar to
+Mozilla's Raptor testing framework. Browsertime is written in Node.js
+and uses Selenium WebDriver to drive multiple browsers including
+Chrome, Chrome for Android, Firefox, and (pending the resolution of
+[Bug 1525126](https://bugzilla.mozilla.org/show_bug.cgi?id=1525126)
+and similar tickets) Firefox for Android and GeckoView-based vehicles.
+
+Right now a custom version of browsertime and the underlying
+geckodriver binary are needed to support GeckoView-based vehicles;
+this module accommodates those in-progress custom versions.
+
+To get started, run
+```
+./mach browsertime --setup [--clobber]
+```
+This will populate `tools/browsertime/node_modules`.
+
+To invoke browsertime, run
+```
+./mach browsertime [ARGS]
+```
+All arguments are passed through to browsertime.
+"""
+
+from __future__ import absolute_import, print_function, unicode_literals
+
+import argparse
+import collections
+import json
+import logging
+import os
+import stat
+import sys
+import re
+import contextlib
+
+from six import StringIO
+from mach.decorators import CommandArgument, CommandProvider, Command
+from mozbuild.base import MachCommandBase, BinaryNotFoundException
+from mozbuild.util import mkdir
+import mozpack.path as mozpath
+
+
+AUTOMATION = "MOZ_AUTOMATION" in os.environ
+BROWSERTIME_ROOT = os.path.dirname(__file__)
+PILLOW_VERSION = "6.0.0"
+PYSSIM_VERSION = "0.4"
+
+
+@contextlib.contextmanager
+def silence():
+ oldout, olderr = sys.stdout, sys.stderr
+ try:
+ sys.stdout, sys.stderr = StringIO(), StringIO()
+ yield
+ finally:
+ sys.stdout, sys.stderr = oldout, olderr
+
+
+def node_path():
+ from mozbuild.nodeutil import find_node_executable
+
+ node, _ = find_node_executable()
+
+ return os.path.abspath(node)
+
+
+def package_path():
+ """The path to the `browsertime` directory.
+
+ Override the default with the `BROWSERTIME` environment variable."""
+ override = os.environ.get("BROWSERTIME", None)
+ if override:
+ return override
+
+ return mozpath.join(BROWSERTIME_ROOT, "node_modules", "browsertime")
+
+
+def browsertime_path():
+ """The path to the `browsertime.js` script."""
+ # On Windows, invoking `node_modules/.bin/browsertime{.cmd}`
+ # doesn't work when invoked as an argument to our specific
+ # binary. Since we want our version of node, invoke the
+ # actual script directly.
+ return mozpath.join(package_path(), "bin", "browsertime.js")
+
+
+def visualmetrics_path():
+ """The path to the `visualmetrics.py` script."""
+ return mozpath.join(package_path(), "browsertime", "visualmetrics.py")
+
+
+def host_platform():
+ is_64bits = sys.maxsize > 2 ** 32
+
+ if sys.platform.startswith("win"):
+ if is_64bits:
+ return "win64"
+ elif sys.platform.startswith("linux"):
+ if is_64bits:
+ return "linux64"
+ elif sys.platform.startswith("darwin"):
+ return "darwin"
+
+ raise ValueError("sys.platform is not yet supported: {}".format(sys.platform))
+
+
+# Map from `host_platform()` to a `fetch`-like syntax.
+host_fetches = {
+ "darwin": {
+ "ffmpeg": {
+ "type": "static-url",
+ "url": "https://github.com/ncalexan/geckodriver/releases/download/v0.24.0-android/ffmpeg-4.1.1-macos64-static.zip", # noqa
+ # An extension to `fetch` syntax.
+ "path": "ffmpeg-4.1.1-macos64-static",
+ },
+ },
+ "linux64": {
+ "ffmpeg": {
+ "type": "static-url",
+ "url": "https://github.com/ncalexan/geckodriver/releases/download/v0.24.0-android/ffmpeg-4.1.4-i686-static.tar.xz", # noqa
+ # An extension to `fetch` syntax.
+ "path": "ffmpeg-4.1.4-i686-static",
+ },
+ # TODO: install a static ImageMagick. All easily available binaries are
+ # not statically linked, so they will (mostly) fail at runtime due to
+ # missing dependencies. For now we require folks to install ImageMagick
+ # globally with their package manager of choice.
+ },
+ "win64": {
+ "ffmpeg": {
+ "type": "static-url",
+ "url": "https://github.com/ncalexan/geckodriver/releases/download/v0.24.0-android/ffmpeg-4.1.1-win64-static.zip", # noqa
+ # An extension to `fetch` syntax.
+ "path": "ffmpeg-4.1.1-win64-static",
+ },
+ "ImageMagick": {
+ "type": "static-url",
+ # 'url': 'https://imagemagick.org/download/binaries/ImageMagick-7.0.8-39-portable-Q16-x64.zip', # noqa
+ # imagemagick.org doesn't keep old versions; the mirror below does.
+ "url": "https://ftp.icm.edu.pl/packages/ImageMagick/binaries/ImageMagick-7.0.8-39-portable-Q16-x64.zip", # noqa
+ # An extension to `fetch` syntax.
+ "path": "ImageMagick-7.0.8",
+ },
+ },
+}
+
+
+@CommandProvider
+class MachBrowsertime(MachCommandBase):
+ @property
+ def artifact_cache_path(self):
+ r"""Downloaded artifacts will be kept here."""
+ # The convention is $MOZBUILD_STATE_PATH/cache/$FEATURE.
+ return mozpath.join(self._mach_context.state_dir, "cache", "browsertime")
+
+ @property
+ def state_path(self):
+ r"""Unpacked artifacts will be kept here."""
+ # The convention is $MOZBUILD_STATE_PATH/$FEATURE.
+ return mozpath.join(self._mach_context.state_dir, "browsertime")
+
+ def setup_prerequisites(self):
+ r"""Install browsertime and visualmetrics.py prerequisites."""
+
+ from mozbuild.action.tooltool import unpack_file
+ from mozbuild.artifact_cache import ArtifactCache
+
+ if not AUTOMATION and host_platform().startswith("linux"):
+ # On Linux ImageMagick needs to be installed manually, and `mach bootstrap` doesn't
+ # do that (yet). Provide some guidance.
+ try:
+ from shutil import which
+ except ImportError:
+ from shutil_which import which
+
+ im_programs = ("compare", "convert", "mogrify")
+ for im_program in im_programs:
+ prog = which(im_program)
+ if not prog:
+ print(
+ "Error: On Linux, ImageMagick must be on the PATH. "
+ "Install ImageMagick manually and try again (or update PATH). "
+ "On Ubuntu and Debian, try `sudo apt-get install imagemagick`. "
+ "On Fedora, try `sudo dnf install imagemagick`. "
+ "On CentOS, try `sudo yum install imagemagick`."
+ )
+ return 1
+
+ # Download the visualmetrics.py requirements.
+ artifact_cache = ArtifactCache(
+ self.artifact_cache_path, log=self.log, skip_cache=False
+ )
+
+ fetches = host_fetches[host_platform()]
+ for tool, fetch in sorted(fetches.items()):
+ archive = artifact_cache.fetch(fetch["url"])
+ # TODO: assert type, verify sha256 (and size?).
+
+ if fetch.get("unpack", True):
+ cwd = os.getcwd()
+ try:
+ mkdir(self.state_path)
+ os.chdir(self.state_path)
+ self.log(
+ logging.INFO,
+ "browsertime",
+ {"path": archive},
+ "Unpacking temporary location {path}",
+ )
+
+ if "win64" in host_platform() and "imagemagick" in tool.lower():
+ # Windows archive does not contain a subfolder
+ # so we make one for it here
+ mkdir(fetch.get("path"))
+ os.chdir(os.path.join(self.state_path, fetch.get("path")))
+ unpack_file(archive)
+ os.chdir(self.state_path)
+ else:
+ unpack_file(archive)
+
+ # Make sure the expected path exists after extraction
+ path = os.path.join(self.state_path, fetch.get("path"))
+ if not os.path.exists(path):
+ raise Exception("Cannot find an extracted directory: %s" % path)
+
+ try:
+ # Some archives provide binaries that don't have the
+ # executable bit set so we need to set it here
+ for root, dirs, files in os.walk(path):
+ for edir in dirs:
+ loc_to_change = os.path.join(root, edir)
+ st = os.stat(loc_to_change)
+ os.chmod(loc_to_change, st.st_mode | stat.S_IEXEC)
+ for efile in files:
+ loc_to_change = os.path.join(root, efile)
+ st = os.stat(loc_to_change)
+ os.chmod(loc_to_change, st.st_mode | stat.S_IEXEC)
+ except Exception as e:
+ raise Exception(
+ "Could not set executable bit in %s, error: %s"
+ % (path, str(e))
+ )
+ finally:
+ os.chdir(cwd)
+
+ def setup(self, should_clobber=False, new_upstream_url=""):
+ r"""Install browsertime and visualmetrics.py prerequisites and the Node.js package."""
+
+ sys.path.append(mozpath.join(self.topsrcdir, "tools", "lint", "eslint"))
+ import setup_helper
+
+ if not new_upstream_url:
+ self.setup_prerequisites()
+
+ if new_upstream_url:
+ package_json_path = os.path.join(BROWSERTIME_ROOT, "package.json")
+
+ self.log(
+ logging.INFO,
+ "browsertime",
+ {
+ "new_upstream_url": new_upstream_url,
+ "package_json_path": package_json_path,
+ },
+ "Updating browsertime node module version in {package_json_path} "
+ "to {new_upstream_url}",
+ )
+
+ if not re.search("/tarball/[a-f0-9]{40}$", new_upstream_url):
+ raise ValueError(
+ "New upstream URL does not end with /tarball/[a-f0-9]{40}: '%s'"
+ % new_upstream_url
+ )
+
+ with open(package_json_path) as f:
+ existing_body = json.loads(
+ f.read(), object_pairs_hook=collections.OrderedDict
+ )
+
+ existing_body["devDependencies"]["browsertime"] = new_upstream_url
+
+ updated_body = json.dumps(existing_body)
+
+ with open(package_json_path, "w") as f:
+ f.write(updated_body)
+
+ # Install the browsertime Node.js requirements.
+ if not setup_helper.check_node_executables_valid():
+ return 1
+
+ # 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 AUTOMATION:
+ os.environ["CHROMEDRIVER_SKIP_DOWNLOAD"] = "true"
+ os.environ["GECKODRIVER_SKIP_DOWNLOAD"] = "true"
+
+ self.log(
+ logging.INFO,
+ "browsertime",
+ {"package_json": mozpath.join(BROWSERTIME_ROOT, "package.json")},
+ "Installing browsertime node module from {package_json}",
+ )
+ status = setup_helper.package_setup(
+ BROWSERTIME_ROOT,
+ "browsertime",
+ should_update=new_upstream_url != "",
+ should_clobber=should_clobber,
+ no_optional=new_upstream_url or AUTOMATION,
+ )
+
+ if status:
+ return status
+
+ if new_upstream_url or AUTOMATION:
+ return 0
+
+ return self.check()
+
+ def node(self, args):
+ r"""Invoke node (interactively) with the given arguments."""
+ return self.run_process(
+ [node_path()] + args,
+ append_env=self.append_env(),
+ pass_thru=True, # Allow user to run Node interactively.
+ ensure_exit_code=False, # Don't throw on non-zero exit code.
+ cwd=mozpath.join(self.topsrcdir),
+ )
+
+ def append_env(self, append_path=True):
+ fetches = host_fetches[host_platform()]
+
+ # Ensure that bare `ffmpeg` and ImageMagick commands
+ # {`convert`,`compare`,`mogrify`} are found. The `visualmetrics.py`
+ # script doesn't take these as configuration, so we do this (for now).
+ # We should update the script itself to accept this configuration.
+ path = os.environ.get("PATH", "").split(os.pathsep) if append_path else []
+ path_to_ffmpeg = mozpath.join(self.state_path, fetches["ffmpeg"]["path"])
+
+ path_to_imagemagick = None
+ if "ImageMagick" in fetches:
+ path_to_imagemagick = mozpath.join(
+ self.state_path, fetches["ImageMagick"]["path"]
+ )
+
+ if path_to_imagemagick:
+ # ImageMagick ships ffmpeg (on Windows, at least) so we
+ # want to ensure that our ffmpeg goes first, just in case.
+ path.insert(
+ 0,
+ self.state_path
+ if host_platform().startswith("win")
+ else mozpath.join(path_to_imagemagick, "bin"),
+ ) # noqa
+ path.insert(
+ 0,
+ path_to_ffmpeg
+ if host_platform().startswith("linux")
+ else mozpath.join(path_to_ffmpeg, "bin"),
+ ) # noqa
+
+ # Ensure that bare `node` and `npm` in scripts, including post-install
+ # scripts, finds the binary we're invoking with. Without this, it's
+ # easy for compiled extensions to get mismatched versions of the Node.js
+ # extension API.
+ node_dir = os.path.dirname(node_path())
+ path = [node_dir] + path
+
+ # On windows, we need to add the ImageMagick directory to the path
+ # otherwise compare won't be found, and the built-in OS convert
+ # method will be used instead of the ImageMagick one.
+ if "win64" in host_platform() and path_to_imagemagick:
+ # Bug 1596237 - In the windows ImageMagick distribution, the ffmpeg
+ # binary is directly located in the root directory, so here we
+ # insert in the 3rd position to avoid taking precedence over ffmpeg
+ path.insert(2, path_to_imagemagick)
+
+ # On macOs, we can't install our own ImageMagick because the
+ # System Integrity Protection (SIP) won't let us set DYLD_LIBRARY_PATH
+ # unless we deactivate SIP with "csrutil disable".
+ # So we're asking the user to install it.
+ #
+ # if ImageMagick was installed via brew, we want to make sure we
+ # include the PATH
+ if host_platform() == "darwin":
+ for p in os.environ["PATH"].split(os.pathsep):
+ p = p.strip()
+ if not p or p in path:
+ continue
+ path.append(p)
+
+ append_env = {
+ "PATH": os.pathsep.join(path),
+ # Bug 1560193: The JS library browsertime uses to execute commands
+ # (execa) will muck up the PATH variable and put the directory that
+ # node is in first in path. If this is globally-installed node,
+ # that means `/usr/bin` will be inserted first which means that we
+ # will get `/usr/bin/python` for `python`.
+ #
+ # Our fork of browsertime supports a `PYTHON` environment variable
+ # that points to the exact python executable to use.
+ "PYTHON": self.virtualenv_manager.python_path,
+ }
+
+ if path_to_imagemagick:
+ append_env.update(
+ {
+ # See https://imagemagick.org/script/download.php. Harmless on other
+ # platforms.
+ "LD_LIBRARY_PATH": mozpath.join(path_to_imagemagick, "lib"),
+ "DYLD_LIBRARY_PATH": mozpath.join(path_to_imagemagick, "lib"),
+ "MAGICK_HOME": path_to_imagemagick,
+ }
+ )
+
+ return append_env
+
+ def _need_install(self, package):
+ from pip._internal.req.constructors import install_req_from_line
+
+ req = install_req_from_line(package)
+ req.check_if_exists(use_user_site=False)
+ if req.satisfied_by is None:
+ return True
+ venv_site_lib = os.path.abspath(
+ os.path.join(self.virtualenv_manager.bin_path, "..", "lib")
+ )
+ site_packages = os.path.abspath(req.satisfied_by.location)
+ return not site_packages.startswith(venv_site_lib)
+
+ def activate_virtualenv(self, *args, **kwargs):
+ r"""Activates virtualenv.
+
+ This function will also install Pillow and pyssim if needed.
+ It will raise an error in case the install failed.
+ """
+ MachCommandBase.activate_virtualenv(self, *args, **kwargs)
+
+ # installing Python deps on the fly
+ for dep in ("Pillow==%s" % PILLOW_VERSION, "pyssim==%s" % PYSSIM_VERSION):
+ if self._need_install(dep):
+ self.virtualenv_manager._run_pip(["install", dep])
+
+ def check(self):
+ r"""Run `visualmetrics.py --check`."""
+ self.activate_virtualenv()
+
+ args = ["--check"]
+ status = self.run_process(
+ [self.virtualenv_manager.python_path, visualmetrics_path()] + args,
+ # For --check, don't allow user's path to interfere with
+ # path testing except on Linux, where ImageMagick needs to
+ # be installed manually.
+ append_env=self.append_env(append_path=host_platform().startswith("linux")),
+ pass_thru=True,
+ ensure_exit_code=False, # Don't throw on non-zero exit code.
+ cwd=mozpath.join(self.topsrcdir),
+ )
+
+ sys.stdout.flush()
+ sys.stderr.flush()
+
+ if status:
+ return status
+
+ # Avoid logging the command (and, on Windows, the environment).
+ self.log_manager.terminal_handler.setLevel(logging.CRITICAL)
+ print("browsertime version:", end=" ")
+
+ sys.stdout.flush()
+ sys.stderr.flush()
+
+ return self.node([browsertime_path()] + ["--version"])
+
+ 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.
+
+ 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("(--browser|-b)[= ]([\w]+)", " ".join(args))
+ if res == []:
+ return None
+ return res[0][-1]
+
+ def matches(args, *flags):
+ "Return 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
+
+ extra_args = []
+
+ # Default to Firefox. Override with `-b ...` or `--browser=...`.
+ specifies_browser = matches(args, "-b", "--browser")
+ if not specifies_browser:
+ extra_args.extend(("-b", "firefox"))
+
+ # Default to not collect HAR. Override with `--skipHar=false`.
+ specifies_har = matches(args, "--har", "--skipHar", "--gzipHar")
+ if not specifies_har:
+ extra_args.append("--skipHar")
+
+ if not matches(args, "--android"):
+ # 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.
+ specifies_binaryPath = matches(
+ args,
+ "--firefox.binaryPath",
+ "--firefox.release",
+ "--firefox.nightly",
+ "--firefox.beta",
+ "--firefox.developer",
+ )
+
+ if not specifies_binaryPath:
+ specifies_binaryPath = extract_browser_name(args) == "chrome"
+
+ if not specifies_binaryPath:
+ try:
+ extra_args.extend(("--firefox.binaryPath", self.get_binary_path()))
+ except BinaryNotFoundException as e:
+ self.log(
+ logging.ERROR,
+ "browsertime",
+ {"error": str(e)},
+ "ERROR: {error}",
+ )
+ self.log(
+ logging.INFO,
+ "browsertime",
+ {},
+ "Please run |./mach build| "
+ "or specify a Firefox binary with --firefox.binaryPath.",
+ )
+ return 1
+
+ if extra_args:
+ self.log(
+ logging.DEBUG,
+ "browsertime",
+ {"extra_args": extra_args},
+ "Running browsertime with extra default arguments: {extra_args}",
+ )
+
+ return extra_args
+
+ def _verify_node_install(self):
+ # check if Node is installed
+ sys.path.append(mozpath.join(self.topsrcdir, "tools", "lint", "eslint"))
+ import setup_helper
+
+ with silence():
+ node_valid = setup_helper.check_node_executables_valid()
+ if not node_valid:
+ print("Can't find Node. did you run ./mach bootstrap ?")
+ return False
+
+ # check if the browsertime package has been deployed correctly
+ # for this we just check for the browsertime directory presence
+ if not os.path.exists(browsertime_path()):
+ print("Could not find browsertime.js, try ./mach browsertime --setup")
+ print("If that still fails, try ./mach browsertime --setup --clobber")
+ return False
+
+ return True
+
+ @Command(
+ "browsertime",
+ category="testing",
+ description="Run [browsertime](https://github.com/sitespeedio/browsertime) "
+ "performance tests.",
+ )
+ @CommandArgument(
+ "--verbose",
+ action="store_true",
+ help="Verbose output for what commands the build is running.",
+ )
+ @CommandArgument("--update-upstream-url", default="")
+ @CommandArgument("--setup", default=False, action="store_true")
+ @CommandArgument("--clobber", default=False, action="store_true")
+ @CommandArgument(
+ "--skip-cache",
+ default=False,
+ action="store_true",
+ help="Skip all local caches to force re-fetching remote artifacts.",
+ )
+ @CommandArgument("--check", default=False, action="store_true")
+ @CommandArgument(
+ "--browsertime-help",
+ default=False,
+ action="store_true",
+ help="Show the browsertime help message.",
+ )
+ @CommandArgument("args", nargs=argparse.REMAINDER)
+ def browsertime(
+ self,
+ args,
+ verbose=False,
+ update_upstream_url="",
+ setup=False,
+ clobber=False,
+ skip_cache=False,
+ check=False,
+ browsertime_help=False,
+ ):
+ self._set_log_level(verbose)
+
+ if update_upstream_url:
+ return self.setup(new_upstream_url=update_upstream_url)
+ elif setup:
+ return self.setup(should_clobber=clobber)
+ else:
+ if not self._verify_node_install():
+ return 1
+
+ if check:
+ return self.check()
+
+ if browsertime_help:
+ args.append("--help")
+
+ self.activate_virtualenv()
+ default_args = self.extra_default_args(args)
+ if default_args == 1:
+ return 1
+ return self.node([browsertime_path()] + default_args + args)