1 # This Source Code Form is subject to the terms of the Mozilla Public
2 # License, v. 2.0. If a copy of the MPL was not distributed with this
3 # file, You can obtain one at http://mozilla.org/MPL/2.0/.
5 r
"""Make it easy to install and run [browsertime](https://github.com/sitespeedio/browsertime).
7 Browsertime is a harness for running performance tests, similar to
8 Mozilla's Raptor testing framework. Browsertime is written in Node.js
9 and uses Selenium WebDriver to drive multiple browsers including
10 Chrome, Chrome for Android, Firefox, and (pending the resolution of
11 [Bug 1525126](https://bugzilla.mozilla.org/show_bug.cgi?id=1525126)
12 and similar tickets) Firefox for Android and GeckoView-based vehicles.
14 Right now a custom version of browsertime and the underlying
15 geckodriver binary are needed to support GeckoView-based vehicles;
16 this module accommodates those in-progress custom versions.
20 ./mach browsertime --setup [--clobber]
22 This will populate `tools/browsertime/node_modules`.
24 To invoke browsertime, run
26 ./mach browsertime [ARGS]
28 All arguments are passed through to browsertime.
44 import mozpack
.path
as mozpath
45 from mach
.decorators
import Command
, CommandArgument
46 from mozbuild
.base
import BinaryNotFoundException
, MachCommandBase
47 from mozbuild
.util
import mkdir
48 from six
import StringIO
50 AUTOMATION
= "MOZ_AUTOMATION" in os
.environ
51 BROWSERTIME_ROOT
= os
.path
.dirname(__file__
)
53 PILLOW_VERSION
= "8.4.0" # version 8.4.0 currently supports python 3.6 to 3.10
54 PYSSIM_VERSION
= "0.4"
55 SCIPY_VERSION
= "1.2.3"
56 NUMPY_VERSION
= "1.16.1"
57 OPENCV_VERSION
= "4.5.4.60"
59 py3_minor
= sys
.version_info
.minor
61 SCIPY_VERSION
= "1.7.3"
62 NUMPY_VERSION
= "1.22.0"
63 PILLOW_VERSION
= "9.0.0"
65 MIN_NODE_VERSION
= "16.0.0"
67 IS_APPLE_SILICON
= sys
.platform
.startswith(
69 ) and platform
.processor().startswith("arm")
72 @contextlib.contextmanager
74 oldout
, olderr
= sys
.stdout
, sys
.stderr
76 sys
.stdout
, sys
.stderr
= StringIO(), StringIO()
79 sys
.stdout
, sys
.stderr
= oldout
, olderr
82 def node_path(command_context
):
84 from distutils
.version
import StrictVersion
86 from mozbuild
.nodeutil
import find_node_executable
88 state_dir
= command_context
._mach
_context
.state_dir
89 cache_path
= os
.path
.join(state_dir
, "browsertime", "node-16")
92 "Could not locate a node binary that is at least version {}. ".format(
95 + "Please run `./mach raptor --browsertime -t amazon` to install it "
96 + "from the Taskcluster Toolchain artifacts."
99 # Check standard locations first
100 node_exe
= find_node_executable(min_version
=StrictVersion(MIN_NODE_VERSION
))
101 if node_exe
and (node_exe
[0] is not None):
102 return os
.path
.abspath(node_exe
[0])
103 if not os
.path
.exists(cache_path
):
104 raise Exception(NODE_FAILURE_MSG
)
106 # Check the browsertime-specific node location next
108 if platform
.system() == "Windows":
109 node_name
= "node.exe"
110 node_exe_path
= os
.path
.join(
117 node_exe_path
= os
.path
.join(
125 node_exe
= os
.path
.join(node_exe_path
, node_name
)
126 if not os
.path
.exists(node_exe
):
127 raise Exception(NODE_FAILURE_MSG
)
129 return os
.path
.abspath(node_exe
)
133 """The path to the `browsertime` directory.
135 Override the default with the `BROWSERTIME` environment variable."""
136 override
= os
.environ
.get("BROWSERTIME", None)
140 return mozpath
.join(BROWSERTIME_ROOT
, "node_modules", "browsertime")
143 def browsertime_path():
144 """The path to the `browsertime.js` script."""
145 # On Windows, invoking `node_modules/.bin/browsertime{.cmd}`
146 # doesn't work when invoked as an argument to our specific
147 # binary. Since we want our version of node, invoke the
148 # actual script directly.
149 return mozpath
.join(package_path(), "bin", "browsertime.js")
152 def visualmetrics_path():
153 """The path to the `visualmetrics.py` script."""
154 return mozpath
.join(package_path(), "browsertime", "visualmetrics-portable.py")
158 is_64bits
= sys
.maxsize
> 2 ** 32
160 if sys
.platform
.startswith("win"):
163 elif sys
.platform
.startswith("linux"):
166 elif sys
.platform
.startswith("darwin"):
169 raise ValueError("sys.platform is not yet supported: {}".format(sys
.platform
))
172 # Map from `host_platform()` to a `fetch`-like syntax.
176 "type": "static-url",
177 "url": "https://github.com/mozilla/perf-automation/releases/download/FFMPEG-v4.4.1/ffmpeg-macos.zip", # noqa
178 # An extension to `fetch` syntax.
179 "path": "ffmpeg-macos",
184 "type": "static-url",
185 "url": "https://github.com/mozilla/perf-automation/releases/download/FFMPEG-v4.4.1/ffmpeg-4.4.1-i686-static.tar.xz", # noqa
186 # An extension to `fetch` syntax.
187 "path": "ffmpeg-4.4.1-i686-static",
192 "type": "static-url",
193 "url": "https://github.com/mozilla/perf-automation/releases/download/FFMPEG-v4.4.1/ffmpeg-4.4.1-full_build.zip", # noqa
194 # An extension to `fetch` syntax.
195 "path": "ffmpeg-4.4.1-full_build",
201 def artifact_cache_path(command_context
):
202 r
"""Downloaded artifacts will be kept here."""
203 # The convention is $MOZBUILD_STATE_PATH/cache/$FEATURE.
204 return mozpath
.join(command_context
._mach
_context
.state_dir
, "cache", "browsertime")
207 def state_path(command_context
):
208 r
"""Unpacked artifacts will be kept here."""
209 # The convention is $MOZBUILD_STATE_PATH/$FEATURE.
210 return mozpath
.join(command_context
._mach
_context
.state_dir
, "browsertime")
213 def setup_prerequisites(command_context
):
214 r
"""Install browsertime and visualmetrics.py prerequisites."""
216 from mozbuild
.action
.tooltool
import unpack_file
217 from mozbuild
.artifact_cache
import ArtifactCache
219 # Download the visualmetrics-portable.py requirements.
220 artifact_cache
= ArtifactCache(
221 artifact_cache_path(command_context
),
222 log
=command_context
.log
,
226 fetches
= host_fetches
[host_platform()]
227 for tool
, fetch
in sorted(fetches
.items()):
228 archive
= artifact_cache
.fetch(fetch
["url"])
229 # TODO: assert type, verify sha256 (and size?).
231 if fetch
.get("unpack", True):
234 mkdir(state_path(command_context
))
235 os
.chdir(state_path(command_context
))
240 "Unpacking temporary location {path}",
245 # Make sure the expected path exists after extraction
246 path
= os
.path
.join(state_path(command_context
), fetch
.get("path"))
247 if not os
.path
.exists(path
):
248 raise Exception("Cannot find an extracted directory: %s" % path
)
251 # Some archives provide binaries that don't have the
252 # executable bit set so we need to set it here
253 for root
, dirs
, files
in os
.walk(path
):
255 loc_to_change
= os
.path
.join(root
, edir
)
256 st
= os
.stat(loc_to_change
)
257 os
.chmod(loc_to_change
, st
.st_mode | stat
.S_IEXEC
)
259 loc_to_change
= os
.path
.join(root
, efile
)
260 st
= os
.stat(loc_to_change
)
261 os
.chmod(loc_to_change
, st
.st_mode | stat
.S_IEXEC
)
262 except Exception as e
:
264 "Could not set executable bit in %s, error: %s" % (path
, str(e
))
270 def setup_browsertime(
272 should_clobber
=False,
274 install_vismet_reqs
=False,
276 r
"""Install browsertime and visualmetrics.py prerequisites and the Node.js package."""
278 sys
.path
.append(mozpath
.join(command_context
.topsrcdir
, "tools", "lint", "eslint"))
281 if not new_upstream_url
:
282 setup_prerequisites(command_context
)
285 package_json_path
= os
.path
.join(BROWSERTIME_ROOT
, "package.json")
291 "new_upstream_url": new_upstream_url
,
292 "package_json_path": package_json_path
,
294 "Updating browsertime node module version in {package_json_path} "
295 "to {new_upstream_url}",
298 if not re
.search("/tarball/[a-f0-9]{40}$", new_upstream_url
):
300 "New upstream URL does not end with /tarball/[a-f0-9]{40}: '%s'"
304 with
open(package_json_path
) as f
:
305 existing_body
= json
.loads(
306 f
.read(), object_pairs_hook
=collections
.OrderedDict
309 existing_body
["devDependencies"]["browsertime"] = new_upstream_url
311 updated_body
= json
.dumps(existing_body
)
313 with
open(package_json_path
, "w") as f
:
314 f
.write(updated_body
)
316 # Install the browsertime Node.js requirements.
317 if not setup_helper
.check_node_executables_valid():
320 # To use a custom `geckodriver`, set
321 # os.environ[b"GECKODRIVER_BASE_URL"] = bytes(url)
322 # to an endpoint with binaries named like
323 # https://github.com/sitespeedio/geckodriver/blob/master/install.js#L31.
325 os
.environ
["CHROMEDRIVER_SKIP_DOWNLOAD"] = "true"
326 os
.environ
["GECKODRIVER_SKIP_DOWNLOAD"] = "true"
328 if install_vismet_reqs
:
329 # Hide this behind a flag so we don't install them by default in Raptor
331 logging
.INFO
, "browsertime", {}, "Installing python requirements"
333 activate_browsertime_virtualenv(command_context
)
338 {"package_json": mozpath
.join(BROWSERTIME_ROOT
, "package.json")},
339 "Installing browsertime node module from {package_json}",
342 # Add the mozbuild Node binary path to the OS environment in Apple Silicon.
343 # During the browesertime installation, it seems installation of sitespeed.io
344 # sub dependencies look for a global Node rather than the mozbuild Node binary.
345 # Normally `--scripts-prepend-node-path` should prevent this but it seems to still
346 # look for a system Node in the environment. This logic ensures the same Node is used.
347 node_dir
= os
.path
.dirname(node_path(command_context
))
348 if IS_APPLE_SILICON
and node_dir
not in os
.environ
["PATH"]:
349 os
.environ
["PATH"] += os
.pathsep
+ node_dir
351 status
= setup_helper
.package_setup(
354 should_update
=new_upstream_url
!= "",
355 should_clobber
=should_clobber
,
356 no_optional
=new_upstream_url
or AUTOMATION
,
361 if new_upstream_url
or AUTOMATION
:
363 if install_vismet_reqs
:
364 return check(command_context
)
369 def node(command_context
, args
):
370 r
"""Invoke node (interactively) with the given arguments."""
371 return command_context
.run_process(
372 [node_path(command_context
)] + args
,
373 append_env
=append_env(command_context
),
374 pass_thru
=True, # Allow user to run Node interactively.
375 ensure_exit_code
=False, # Don't throw on non-zero exit code.
376 cwd
=mozpath
.join(command_context
.topsrcdir
),
380 def append_env(command_context
, append_path
=True):
381 fetches
= host_fetches
[host_platform()]
383 # Ensure that `ffmpeg` is found and added to the environment
384 path
= os
.environ
.get("PATH", "").split(os
.pathsep
) if append_path
else []
385 path_to_ffmpeg
= mozpath
.join(
386 state_path(command_context
), fetches
["ffmpeg"]["path"]
392 if host_platform().startswith("linux")
393 else mozpath
.join(path_to_ffmpeg
, "bin"),
396 # Ensure that bare `node` and `npm` in scripts, including post-install
397 # scripts, finds the binary we're invoking with. Without this, it's
398 # easy for compiled extensions to get mismatched versions of the Node.js
400 node_dir
= os
.path
.dirname(node_path(command_context
))
401 path
= [node_dir
] + path
404 "PATH": os
.pathsep
.join(path
),
405 # Bug 1560193: The JS library browsertime uses to execute commands
406 # (execa) will muck up the PATH variable and put the directory that
407 # node is in first in path. If this is globally-installed node,
408 # that means `/usr/bin` will be inserted first which means that we
409 # will get `/usr/bin/python` for `python`.
411 # Our fork of browsertime supports a `PYTHON` environment variable
412 # that points to the exact python executable to use.
413 "PYTHON": command_context
.virtualenv_manager
.python_path
,
419 def _need_install(command_context
, package
):
420 from pip
._internal
.req
.constructors
import install_req_from_line
422 req
= install_req_from_line(package
)
423 req
.check_if_exists(use_user_site
=False)
424 if req
.satisfied_by
is None:
426 venv_site_lib
= os
.path
.abspath(
427 os
.path
.join(command_context
.virtualenv_manager
.bin_path
, "..", "lib")
429 site_packages
= os
.path
.abspath(req
.satisfied_by
.location
)
430 return not site_packages
.startswith(venv_site_lib
)
433 def activate_browsertime_virtualenv(command_context
, *args
, **kwargs
):
434 r
"""Activates virtualenv.
436 This function will also install Pillow and pyssim if needed.
437 It will raise an error in case the install failed.
439 # TODO: Remove `./mach browsertime` completely, as a follow up to Bug 1758990
440 MachCommandBase
.activate_virtualenv(command_context
, *args
, **kwargs
)
442 # installing Python deps on the fly
444 "Pillow==%s" % PILLOW_VERSION
,
445 "pyssim==%s" % PYSSIM_VERSION
,
446 "scipy==%s" % SCIPY_VERSION
,
447 "numpy==%s" % NUMPY_VERSION
,
448 "opencv-python==%s" % OPENCV_VERSION
,
450 if _need_install(command_context
, dep
):
451 subprocess
.check_call(
453 command_context
.virtualenv_manager
.python_path
,
462 def check(command_context
):
463 r
"""Run `visualmetrics.py --check`."""
464 command_context
.activate_virtualenv()
467 status
= command_context
.run_process(
468 [command_context
.virtualenv_manager
.python_path
, visualmetrics_path()] + args
,
469 # For --check, don't allow user's path to interfere with path testing except on Linux
470 append_env
=append_env(
471 command_context
, append_path
=host_platform().startswith("linux")
474 ensure_exit_code
=False, # Don't throw on non-zero exit code.
475 cwd
=mozpath
.join(command_context
.topsrcdir
),
484 # Avoid logging the command (and, on Windows, the environment).
485 command_context
.log_manager
.terminal_handler
.setLevel(logging
.CRITICAL
)
486 print("browsertime version:", end
=" ")
491 return node(command_context
, [browsertime_path()] + ["--version"])
494 def extra_default_args(command_context
, args
=[]):
495 # Add Mozilla-specific default arguments. This is tricky because browsertime is quite
496 # loose about arguments; repeat arguments are generally accepted but then produce
497 # difficult to interpret type errors.
499 def extract_browser_name(args
):
500 "Extracts the browser name if any"
501 # These are BT arguments, it's BT job to check them
502 # here we just want to extract the browser name
503 res
= re
.findall("(--browser|-b)[= ]([\w]+)", " ".join(args
))
508 def matches(args
, *flags
):
509 "Return True if any argument matches any of the given flags (maybe with an argument)."
511 if flag
in args
or any(arg
.startswith(flag
+ "=") for arg
in args
):
517 # Default to Firefox. Override with `-b ...` or `--browser=...`.
518 specifies_browser
= matches(args
, "-b", "--browser")
519 if not specifies_browser
:
520 extra_args
.extend(("-b", "firefox"))
522 # Default to not collect HAR. Override with `--skipHar=false`.
523 specifies_har
= matches(args
, "--har", "--skipHar", "--gzipHar")
524 if not specifies_har
:
525 extra_args
.append("--skipHar")
527 if not matches(args
, "--android"):
528 # If --firefox.binaryPath is not specified, default to the objdir binary
529 # Note: --firefox.release is not a real browsertime option, but it will
530 # silently ignore it instead and default to a release installation.
531 specifies_binaryPath
= matches(
533 "--firefox.binaryPath",
537 "--firefox.developer",
540 if not specifies_binaryPath
:
541 specifies_binaryPath
= extract_browser_name(args
) == "chrome"
543 if not specifies_binaryPath
:
546 ("--firefox.binaryPath", command_context
.get_binary_path())
548 except BinaryNotFoundException
as e
:
559 "Please run |./mach build| "
560 "or specify a Firefox binary with --firefox.binaryPath.",
568 {"extra_args": extra_args
},
569 "Running browsertime with extra default arguments: {extra_args}",
575 def _verify_node_install(command_context
):
576 # check if Node is installed
577 sys
.path
.append(mozpath
.join(command_context
.topsrcdir
, "tools", "lint", "eslint"))
581 node_valid
= setup_helper
.check_node_executables_valid()
583 print("Can't find Node. did you run ./mach bootstrap ?")
586 # check if the browsertime package has been deployed correctly
587 # for this we just check for the browsertime directory presence
588 if not os
.path
.exists(browsertime_path()):
589 print("Could not find browsertime.js, try ./mach browsertime --setup")
590 print("If that still fails, try ./mach browsertime --setup --clobber")
599 description
="Run [browsertime](https://github.com/sitespeedio/browsertime) "
600 "performance tests.",
605 help="Verbose output for what commands the build is running.",
607 @CommandArgument("--update-upstream-url", default
="")
608 @CommandArgument("--setup", default
=False, action
="store_true")
609 @CommandArgument("--clobber", default
=False, action
="store_true")
614 help="Skip all local caches to force re-fetching remote artifacts.",
616 @CommandArgument("--check-browsertime", default
=False, action
="store_true")
618 "--install-vismet-reqs",
621 help="Add this flag to get the visual metrics requirements installed.",
624 "--browsertime-help",
627 help="Show the browsertime help message.",
629 @CommandArgument("args", nargs
=argparse
.REMAINDER
)
634 update_upstream_url
="",
638 check_browsertime
=False,
639 browsertime_help
=False,
640 install_vismet_reqs
=False,
642 command_context
._set
_log
_level
(verbose
)
644 # Output a message before going further to make sure the
645 # user knows that this tool is unsupported by the perftest
646 # team and point them to our supported tools. Pause a bit to
647 # make sure the user sees this message.
652 "[INFO] This command should be used for browsertime setup only.\n"
653 "If you are looking to run performance tests on your patch, use "
654 "`./mach raptor --browsertime` instead.\n\nYou can get visual-metrics "
655 "by using the --browsertime-video and --browsertime-visualmetrics. "
656 "Here is a sample command for raptor-browsertime: \n\t`./mach raptor "
657 "--browsertime -t amazon --browsertime-video --browsertime-visualmetrics`\n\n"
658 "See this wiki page for more information if needed: "
659 "https://wiki.mozilla.org/TestEngineering/Performance/Raptor/Browsertime\n\n",
663 if update_upstream_url
:
664 return setup_browsertime(
666 new_upstream_url
=update_upstream_url
,
667 install_vismet_reqs
=install_vismet_reqs
,
670 return setup_browsertime(
672 should_clobber
=clobber
,
673 install_vismet_reqs
=install_vismet_reqs
,
676 if not _verify_node_install(command_context
):
679 if check_browsertime
:
680 return check(command_context
)
683 args
.append("--help")
685 activate_browsertime_virtualenv(command_context
)
686 default_args
= extra_default_args(command_context
, args
)
687 if default_args
== 1:
689 return node(command_context
, [browsertime_path()] + default_args
+ args
)