| # Copyright 2022 The Chromium Authors |
| # Use of this source code is governed by a BSD-style license that can be |
| # found in the LICENSE file. |
| |
| from __future__ import annotations |
| |
| import argparse |
| import datetime as dt |
| import json |
| import logging |
| import sys |
| import tempfile |
| import textwrap |
| import traceback |
| from typing import (TYPE_CHECKING, Any, Dict, List, Optional, Sequence, Tuple, |
| Type, Union) |
| |
| import tabulate as tbl |
| |
| import crossbench.benchmarks.all as benchmarks |
| from crossbench import __version__ |
| from crossbench import path as pth |
| from crossbench import plt |
| from crossbench.benchmarks.base import Benchmark |
| from crossbench.browsers import splash_screen, viewport |
| from crossbench.browsers.browser_helper import BROWSERS_CACHE |
| from crossbench.cli import ui |
| from crossbench.cli.config.browser import BrowserConfig |
| from crossbench.cli.config.browser_variants import BrowserVariantsConfig |
| from crossbench.cli.config.env import (parse_env_config_file, |
| parse_inline_env_config) |
| from crossbench.cli.config.network import NetworkConfig |
| from crossbench.cli.config.probe import (PROBE_LOOKUP, ProbeConfig, |
| ProbeListConfig) |
| from crossbench.cli.config.secrets import SecretsConfig |
| from crossbench.cli.parser import CrossBenchArgumentParser |
| from crossbench.cli.subcommand.devtools_recorder_proxy.default import \ |
| CrossbenchDevToolsRecorderProxy |
| from crossbench.env import (HostEnvironment, HostEnvironmentConfig, |
| ValidationMode) |
| from crossbench.parse import (DurationParser, LateArgumentError, ObjectParser, |
| PathParser) |
| from crossbench.probes.all import GENERAL_PURPOSE_PROBES, DebuggerProbe |
| from crossbench.probes.internal import ErrorsProbe |
| from crossbench.runner.runner import Runner |
| from crossbench.runner.timing import Timing |
| |
| if TYPE_CHECKING: |
| from crossbench.browsers.browser import Browser |
| from crossbench.probes.probe import Probe |
| from crossbench.runner.run import Run |
| BenchmarkClsT = Type[Benchmark] |
| BrowserLookupTableT = Dict[str, Tuple[Type[Browser], pth.LocalPath]] |
| |
| |
| class CrossBenchArgumentError(argparse.ArgumentError): |
| """Custom class that also prints the argument.help if available. |
| """ |
| |
| def __init__(self, argument: Any, message: str) -> None: |
| self.help: str = "" |
| super().__init__(argument, message) |
| if self.argument_name: |
| self.help = getattr(argument, "help", "") |
| |
| def __str__(self) -> str: |
| formatted = super().__str__() |
| if not self.help: |
| return formatted |
| return (f"argument error {self.argument_name}:\n\n" |
| f"Help {self.argument_name}:\n{self.help}\n\n" |
| f"{formatted}") |
| |
| |
| argparse.ArgumentError = CrossBenchArgumentError |
| |
| |
| class EnableDebuggingAction(argparse.Action): |
| """Custom action to set both --throw and -vvv.""" |
| |
| def __call__(self, |
| parser: argparse.ArgumentParser, |
| namespace: argparse.Namespace, |
| values: Union[str, Sequence[Any], None], |
| option_string: Optional[str] = None) -> None: |
| setattr(namespace, "throw", True) |
| setattr(namespace, "verbosity", 3) |
| setattr(namespace, "driver_logging", True) |
| |
| |
| class EnableFastAction(argparse.Action): |
| """Custom action to enable fast test runs""" |
| |
| def __call__(self, |
| parser: argparse.ArgumentParser, |
| namespace: argparse.Namespace, |
| values: Union[str, Sequence[Any], None], |
| option_string: Optional[str] = None) -> None: |
| setattr(namespace, "cool_down_time", dt.timedelta()) |
| setattr(namespace, "splash_screen", splash_screen.SplashScreen.NONE) |
| setattr(namespace, "env_validation", ValidationMode.SKIP) |
| |
| |
| class AppendDebuggerProbeAction(argparse.Action): |
| """Custom action to set multiple args when --gdb or --lldb are set: |
| - Add a DebuggerProbe config. |
| - Increase --timeout-unit to a large value to keep debug session alive for a |
| longer time. |
| """ |
| |
| def __call__(self, |
| parser: argparse.ArgumentParser, |
| namespace: argparse.Namespace, |
| values: Union[str, Sequence[Any], None], |
| option_string: Optional[str] = None) -> None: |
| probes: List[ProbeConfig] = getattr(namespace, self.dest, []) |
| probe_settings = {"debugger": "gdb"} |
| if option_string and "lldb" in option_string: |
| probe_settings["debugger"] = "lldb" |
| probes.append(ProbeConfig(DebuggerProbe, probe_settings)) |
| if not getattr(namespace, "timeout_unit", None): |
| # Set a very large --timeout-unit to allow for very slow debugging without |
| # causing timeouts (for instance when waiting on a breakpoint). |
| setattr(namespace, "timeout_unit", dt.timedelta.max) |
| |
| |
| class MainCrossBenchArgumentParser(CrossBenchArgumentParser): |
| |
| def print_help(self, file=None) -> None: |
| super().print_help(file=file) |
| self.print_probes(file=file) |
| |
| def print_probes(self, file=None) -> None: |
| lines = [ |
| "Probes can be added and configured for each benchmark.", |
| f"Use `{sys.argv[0]} describe probe $PROBE` for the full help.", |
| "", |
| "Usage: --probe=v8.log --probe=video ...", |
| "Usage: --probe=v8.log:{log_all:false} ...", |
| "Usage: --probe-config=configs/probe/perfetto/default.config.hjson", |
| "", |
| ] |
| table = [] |
| for probe_cls in GENERAL_PURPOSE_PROBES: |
| table.append((probe_cls.NAME, probe_cls.summary_text())) |
| lines.append(tbl.tabulate(table, tablefmt="plain")) |
| contents = "\n".join(lines) |
| file = file or sys.stdout |
| file.write("\n") |
| file.write("Available Probes for all Benchmarks:\n") |
| file.write(textwrap.indent(contents, " ")) |
| file.write("\n") |
| |
| |
| class CrossBenchCLI: |
| BENCHMARKS: Tuple[BenchmarkClsT, ...] = ( |
| benchmarks.JetStream20Benchmark, |
| benchmarks.JetStream21Benchmark, |
| benchmarks.JetStream22Benchmark, |
| benchmarks.JetStream30Benchmark, |
| benchmarks.LoadLinePhoneBenchmark, |
| benchmarks.LoadLineTabletBenchmark, |
| benchmarks.ManualBenchmark, |
| benchmarks.MotionMark10Benchmark, |
| benchmarks.MotionMark11Benchmark, |
| benchmarks.MotionMark12Benchmark, |
| benchmarks.MotionMark13Benchmark, |
| benchmarks.PageLoadBenchmark, |
| benchmarks.PowerBenchmark, |
| benchmarks.Speedometer20Benchmark, |
| benchmarks.Speedometer21Benchmark, |
| benchmarks.Speedometer30Benchmark, |
| benchmarks.MemoryBenchmark, |
| ) |
| |
| RUNNER_CLS: Type[Runner] = Runner |
| |
| def __init__(self, enable_logging: bool = True) -> None: |
| self._enable_logging = enable_logging |
| self._console_handler: Optional[logging.StreamHandler] = None |
| self._subparsers: Dict[BenchmarkClsT, CrossBenchArgumentParser] = {} |
| self.parser = MainCrossBenchArgumentParser( |
| description=("A cross browser and cross benchmark runner " |
| "with configurable measurement probes.")) |
| self.describe_parser = CrossBenchArgumentParser() |
| self.recorder_parser = CrossBenchArgumentParser() |
| self.args = argparse.Namespace() |
| self._setup_parser() |
| self._setup_subparser() |
| |
| def _setup_parser(self) -> None: |
| self._add_verbosity_argument(self.parser) |
| # Disable colors by default when piped to a file. |
| has_color = hasattr(sys.stdout, "isatty") and sys.stdout.isatty() |
| self.parser.add_argument( |
| "--no-color", |
| dest="color", |
| action="store_false", |
| default=has_color, |
| help="Disable colored output") |
| self.parser.add_argument( |
| "--version", action="version", version=f"%(prog)s {__version__}") |
| |
| def _add_verbosity_argument(self, parser: argparse.ArgumentParser) -> None: |
| debug_group = parser.add_argument_group("Verbosity / Debugging Options") |
| verbosity_group = debug_group.add_mutually_exclusive_group() |
| verbosity_group.add_argument( |
| "--quiet", |
| "-q", |
| dest="verbosity", |
| default=0, |
| action="store_const", |
| const=-1, |
| help="Disable most output printing.") |
| verbosity_group.add_argument( |
| "--verbose", |
| "-v", |
| dest="verbosity", |
| action="count", |
| default=0, |
| help=("Increase output verbosity. " |
| "Repeat for more verbose output (0..2).")) |
| debug_group.add_argument( |
| "--driver-logging", |
| "--verbose-driver", |
| action="store_true", |
| default=False, |
| help=("Enable verbose webdriver logging. " |
| "Disabled by default, automatically enable with --debug")) |
| debug_group.add_argument( |
| "--throw", |
| action="store_true", |
| default=False, |
| help=("Directly throw exceptions instead. " |
| "Note that this prevents merging of probe results if only " |
| "a subset of the runs failed.")) |
| debug_group.add_argument( |
| "--debug", |
| action=EnableDebuggingAction, |
| nargs=0, |
| help="Enable debug output, equivalent to --throw -vvv") |
| |
| debugger_group = debug_group.add_mutually_exclusive_group() |
| debugger_group.add_argument( |
| "--gdb", |
| action=AppendDebuggerProbeAction, |
| nargs=0, |
| dest="probe", |
| help=("Launch chrome with gdb or lldb attached to all processes. " |
| " See 'describe probe debugger' for more options.")) |
| debugger_group.add_argument( |
| "--lldb", |
| action=AppendDebuggerProbeAction, |
| nargs=0, |
| dest="probe", |
| help=("Launch chrome with lldb attached to all processes." |
| " See 'describe probe debugger' for more options.")) |
| |
| def _setup_subparser(self) -> None: |
| self.subparsers = self.parser.add_subparsers( |
| title="Subcommands", |
| dest="subcommand", |
| required=True, |
| parser_class=CrossBenchArgumentParser) |
| for benchmark_cls in self.BENCHMARKS: |
| self._setup_benchmark_subparser(benchmark_cls) |
| self._setup_help_subparser() |
| self._setup_describe_subparser() |
| self._setup_recorder_subparser() |
| |
| def _setup_recorder_subparser(self) -> None: |
| self.recorder_parser = CrossbenchDevToolsRecorderProxy.add_subcommand( |
| self.subparsers) |
| assert isinstance(self.recorder_parser, CrossBenchArgumentParser) |
| self._add_verbosity_argument(self.recorder_parser) |
| |
| def _setup_describe_subparser(self) -> None: |
| self.describe_parser = self.subparsers.add_parser( |
| "describe", aliases=["desc"], help="Print all benchmarks and stories") |
| assert isinstance(self.describe_parser, CrossBenchArgumentParser) |
| self.describe_parser.add_argument( |
| "category", |
| nargs="?", |
| choices=["all", "benchmark", "benchmarks", "probe", "probes"], |
| default="all", |
| help="Limit output to the given category, defaults to 'all'") |
| self.describe_parser.add_argument( |
| "filter", |
| nargs="?", |
| help=("Only display the given item from the provided category. " |
| "By default all items are displayed. " |
| "Example: describe probes v8.log")) |
| self.describe_parser.add_argument( |
| "--json", |
| default=False, |
| action="store_true", |
| help="Print the data as json data") |
| self.describe_parser.set_defaults(subcommand_fn=self.describe_subcommand) |
| self._add_verbosity_argument(self.describe_parser) |
| |
| def _setup_help_subparser(self) -> None: |
| # Just for completeness we want to support "--help" and "help" |
| help_parser = self.subparsers.add_parser( |
| "help", help="Print the top-level, same as --help") |
| help_parser.set_defaults(subcommand_fn=self.help_subcommand) |
| version_parser = self.subparsers.add_parser( |
| "version", |
| help="Show program's version number and exit, same as --version") |
| version_parser.set_defaults(subcommand_fn=self.version_subcommand) |
| assert isinstance(self.describe_parser, CrossBenchArgumentParser) |
| self._add_verbosity_argument(self.describe_parser) |
| |
| def describe_subcommand(self, args: argparse.Namespace) -> None: |
| benchmarks_data: Dict[str, Any] = {} |
| for benchmark_cls in self.BENCHMARKS: |
| aliases: Tuple[str, ...] = benchmark_cls.aliases() |
| if args.filter: |
| if benchmark_cls.NAME != args.filter and args.filter not in aliases: |
| continue |
| benchmark_info = benchmark_cls.describe() |
| benchmark_info["aliases"] = aliases or "None" |
| benchmark_info["help"] = f"See `{benchmark_cls.NAME} --help`" |
| benchmarks_data[benchmark_cls.NAME] = benchmark_info |
| data: Dict[str, Dict[str, Any]] = { |
| "benchmarks": benchmarks_data, |
| "probes": { |
| str(probe_cls.NAME): probe_cls.help_text() |
| for probe_cls in GENERAL_PURPOSE_PROBES |
| if not args.filter or probe_cls.NAME == args.filter |
| } |
| } |
| if args.json: |
| if args.category in ("probe", "probes"): |
| data = data["probes"] |
| if not data: |
| self.error(f"No matching probe found: '{args.filter}'") |
| elif args.category in ("benchmark", "benchmarks"): |
| data = data["benchmarks"] |
| if not data: |
| self.error(f"No matching benchmark found: '{args.filter}'") |
| else: |
| assert args.category == "all" |
| if not data["benchmarks"] and not data["probes"]: |
| self.error(f"No matching benchmarks or probes found: '{args.filter}'") |
| print(json.dumps(data, indent=2)) |
| return |
| # Create tabular format |
| printed_any = False |
| if args.category in ("all", "benchmark", "benchmarks"): |
| table: List[List[Optional[str]]] = [["Benchmark", "Property", "Value"]] |
| for benchmark_name, values in data["benchmarks"].items(): |
| table.append([ |
| benchmark_name, |
| ]) |
| for name, value in values.items(): |
| if isinstance(value, (tuple, list)): |
| value = "\n".join(value) |
| elif isinstance(value, dict): |
| if not value.items(): |
| value = "[]" |
| else: |
| kwargs = {"maxcolwidths": 60} |
| value = tbl.tabulate(value.items(), tablefmt="plain", **kwargs) |
| table.append([None, name, value]) |
| if len(table) <= 1: |
| if args.category != "all": |
| self.error(f"No matching benchmark found: '{args.filter}'") |
| else: |
| printed_any = True |
| print(tbl.tabulate(table, tablefmt="grid")) |
| |
| if args.category in ("all", "probe", "probes"): |
| table = [["Probe", "Help"]] |
| for probe_name, probe_desc in data["probes"].items(): |
| table.append([probe_name, probe_desc]) |
| if len(table) <= 1: |
| if args.category != "all": |
| self.error(f"No matching probe found: '{args.filter}'") |
| else: |
| printed_any = True |
| print(tbl.tabulate(table, tablefmt="grid")) |
| |
| if not printed_any: |
| self.error(f"No matching benchmarks or probes found: '{args.filter}'") |
| |
| def help_subcommand(self, args: argparse.Namespace) -> None: |
| del args |
| self.parser.print_help() |
| sys.exit(0) |
| |
| def version_subcommand(self, args: argparse.Namespace) -> None: |
| del args |
| print(f"{sys.argv[0]} {__version__}") |
| sys.exit(0) |
| |
| def _setup_benchmark_subparser(self, benchmark_cls: Type[Benchmark]) -> None: |
| subparser = benchmark_cls.add_cli_parser(self.subparsers, |
| benchmark_cls.aliases()) |
| self.RUNNER_CLS.add_cli_parser(benchmark_cls, subparser) |
| assert isinstance(subparser, argparse.ArgumentParser), ( |
| f"Benchmark class {benchmark_cls}.add_cli_parser did not return " |
| f"an ArgumentParser: {subparser}") |
| self._subparsers[benchmark_cls] = subparser |
| |
| runner_group = subparser.add_argument_group("Runner Options", "") |
| runner_group.add_argument( |
| "--cache-dir", |
| type=pth.LocalPath, |
| default=BROWSERS_CACHE, |
| help=("Used for caching browser binaries and archives. " |
| "Defaults to binary_cache")) |
| |
| cooldown_group = runner_group.add_mutually_exclusive_group() |
| cooldown_group.add_argument( |
| "--cool-down-time", |
| "--cool-down", |
| type=DurationParser.positive_or_zero_duration, |
| default=dt.timedelta(seconds=2), |
| help=("Time the runner waits between different runs or repetitions. " |
| "Increase this to let the CPU cool down between runs. " |
| f"Format: {DurationParser.help()}")) |
| cooldown_group.add_argument( |
| "--no-cool-down", |
| action="store_const", |
| dest="cool_down_time", |
| const=dt.timedelta(seconds=0), |
| help=("Disable cool-down between runs (might cause CPU throttling), " |
| "equivalent to --cool-down=0.")) |
| cooldown_group.add_argument( |
| "--fast", |
| action=EnableFastAction, |
| nargs=0, |
| help=("Switch to a fast run mode " |
| "which might yield unstable performance results. " |
| "Equivalent to --cool-down=0 --no-splash --env-validation=skip.")) |
| |
| runner_group.add_argument( |
| "--time-unit", |
| type=DurationParser.any_duration, |
| default=dt.timedelta(seconds=1), |
| help=("Absolute duration of 1 time unit in the runner. " |
| "Increase this for slow builds or machines. " |
| f"Format: {DurationParser.help()}")) |
| runner_group.add_argument( |
| "--timeout-unit", |
| type=DurationParser.any_duration, |
| default=dt.timedelta(), |
| help=("Absolute duration of 1 time unit for timeouts in the runner. " |
| "Unlike --time-unit, this does only apply for timeouts, " |
| "as opposed to say initial wait times or sleeps." |
| f"Format: {DurationParser.help()}")) |
| runner_group.add_argument( |
| "--run-timeout", |
| type=DurationParser.positive_or_zero_duration, |
| default=dt.timedelta(), |
| help=("Sets the same timeout per run on all browsers. " |
| "Runs will be aborted after the given timeout. " |
| f"Format: {DurationParser.help()}")) |
| runner_group.add_argument( |
| "--start-delay", |
| type=DurationParser.positive_or_zero_duration, |
| default=dt.timedelta(), |
| help=("Delay before running the core workload, " |
| "after a story's/workload's setup, " |
| "and after starting the browser.")) |
| runner_group.add_argument( |
| "--stop-delay", |
| type=DurationParser.positive_or_zero_duration, |
| default=dt.timedelta(), |
| help=("Delay after running the core workload, " |
| "before story's/workload's teardown, " |
| "and before quitting the browser.")) |
| |
| network_group = subparser.add_argument_group("Network Options", "") |
| network_settings_group = network_group.add_mutually_exclusive_group() |
| network_settings_group.add_argument( |
| "--network", |
| type=NetworkConfig.parse, |
| help=("Either an inline network config or an file path to full " |
| "network config hjson file (see --network-config).")) |
| network_settings_group.add_argument( |
| "--network-config", |
| metavar="DIR", |
| type=NetworkConfig.parse_config_path, |
| help=NetworkConfig.help()) |
| network_settings_group.add_argument( |
| "--local-file-server", |
| "--local-fileserver", |
| "--file-server", |
| "--fileserver", |
| type=NetworkConfig.parse_local, |
| metavar="DIR", |
| dest="network", |
| help="Start a local http file server at the given directory.") |
| network_settings_group.add_argument( |
| "--wpr", |
| "--web-page-replay", |
| type=NetworkConfig.parse_wpr, |
| metavar="WPR_ARCHIVE", |
| dest="network", |
| help=("Use wpr.archive to replay network requests " |
| "via a local proxy server. " |
| "Archives can be recorded with --probe=wpr. " |
| "WPR_ARCHIVE can be a local file or a gs:// google storage url.")) |
| |
| env_group = subparser.add_argument_group("Environment Options", "") |
| env_settings_group = env_group.add_mutually_exclusive_group() |
| env_settings_group.add_argument( |
| "--env", |
| type=parse_inline_env_config, |
| help=("Set default runner environment settings. " |
| f"Possible values: {', '.join(HostEnvironment.CONFIGS)}" |
| "or an inline hjson configuration (see --env-config). " |
| "Mutually exclusive with --env-config")) |
| env_settings_group.add_argument( |
| "--env-config", |
| type=parse_env_config_file, |
| help=("Path to an env.config.hjson file that specifies detailed " |
| "runner environment settings and requirements. " |
| "See config/env.config.hjson for more details." |
| "Mutually exclusive with --env")) |
| |
| env_group.add_argument( |
| "--env-validation", |
| default=ValidationMode.PROMPT, |
| type=ValidationMode, |
| help=( |
| "Set how runner env is validated (see als --env-config/--env):\n" + |
| ValidationMode.help_text(indent=2))) |
| env_group.add_argument( |
| "--dry-run", |
| action="store_true", |
| default=False, |
| help="Don't run any browsers or probes") |
| |
| browser_group = subparser.add_argument_group( |
| "Browser Options", "Any other browser option can be passed " |
| "after the '--' arguments separator.") |
| browser_config_group = browser_group.add_mutually_exclusive_group() |
| browser_config_group.add_argument( |
| "--browser", |
| "-b", |
| type=BrowserConfig.parse_with_range, |
| action="extend", |
| default=[], |
| help=( |
| "Browser binary, defaults to 'chrome-stable'." |
| "Use this to test a simple browser variant. " |
| "Use [chrome, chrome-stable, chrome-dev, chrome-canary, " |
| "safari, safari-tp, " |
| "firefox, firefox-stable, firefox-dev, firefox-nightly, " |
| "edge, edge-stable, edge-beta, edge-dev, edge-canary] " |
| "for system default browsers or a full path. \n" |
| "* Use --browser=chrome-M107 to download the latest version for a " |
| "specific milestone\n" |
| "* Use ... to test milestone ranges --browser=chr-M100...M125" |
| "* Use --browser=chrome-100.0.4896.168 to download a specific " |
| "chrome version (macOS and linux for googlers and chrome only). \n" |
| "* Use --browser=path/to/archive.dmg on macOS or " |
| "--browser=path/to/archive.rpm on linux " |
| "for locally cached versions (chrome only).\n" |
| "* Use --browser=\"${ADB_SERIAL}:chrome\" " |
| "(e.g. --browser='0a388e93:chrome') for specific " |
| "android devices or --browser='adb:chrome' if only once device is " |
| "attached.\n" |
| "Repeat for adding multiple browsers. " |
| "The browser result dir's name is " |
| "'${BROWSER}_${PLATFORM}_${INDEX}' " |
| "$INDEX corresponds to the order on the command line." |
| "Cannot be used together with --browser-config")) |
| browser_config_group.add_argument( |
| "--browser-config", |
| type=PathParser.hjson_file_path, |
| help=("Browser configuration.json file. " |
| "Use this to run multiple browsers and/or multiple " |
| "flag configurations. " |
| "See config/doc/browser.config.hjson on how to set up a complex " |
| "configuration file. " |
| "Cannot be used together with --browser.")) |
| browser_group.add_argument( |
| "--driver-path", |
| type=PathParser.file_path, |
| help=("Use the same custom driver path for all specified browsers. " |
| "Version mismatches might cause crashes.")) |
| browser_group.add_argument( |
| "--config", |
| type=PathParser.hjson_file_path, |
| help=("Specify a common config for --probe-config, --browser-config, " |
| "--network-config and --env-config.")) |
| browser_group.add_argument( |
| "--secrets", |
| dest="secrets", |
| type=SecretsConfig.parse, |
| default=SecretsConfig(), |
| help="Path to file containing login secrets") |
| |
| browser_group.add_argument( |
| "--wipe-system-user-data", |
| dest="wipe_system_user_data", |
| default=False, |
| action="store_true", |
| help="Clear user data at the beginning of the test " |
| "(be careful using it).") |
| browser_group.add_argument( |
| "--http-request-timeout", |
| type=DurationParser.positive_or_zero_duration, |
| default=dt.timedelta(), |
| help=("Set the timeout of http request. " |
| f"Format: {DurationParser.help()}. " |
| "When not speficied, there will be no timeout.")) |
| |
| splashscreen_group = browser_group.add_mutually_exclusive_group() |
| splashscreen_group.add_argument( |
| "--splash-screen", |
| "--splashscreen", |
| "--splash", |
| type=splash_screen.SplashScreen.parse, |
| default=splash_screen.SplashScreen.DETAILED, |
| help=("Set the splashscreen shown before each run. " |
| "Choices: 'default', 'none', 'minimal', 'detailed,' or " |
| "a path or a URL.")) |
| splashscreen_group.add_argument( |
| "--no-splash", |
| "--nosplash", |
| dest="splash_screen", |
| const=splash_screen.SplashScreen.NONE, |
| action="store_const", |
| help="Shortcut for --splash-screen=none") |
| |
| viewport_group = browser_group.add_mutually_exclusive_group() |
| # pytype: disable=missing-parameter |
| viewport_group.add_argument( |
| "--viewport", |
| default=viewport.Viewport.DEFAULT, |
| type=viewport.Viewport.parse, |
| help=("Set the browser window position." |
| "Options: size and position, " |
| f"{', '.join(str(e) for e in viewport.ViewportMode)}. " |
| "Examples: --viewport=1550x300 --viewport=fullscreen. " |
| f"Default: {viewport.Viewport.DEFAULT}")) |
| # pytype: enable=missing-parameter |
| viewport_group.add_argument( |
| "--headless", |
| dest="viewport", |
| const=viewport.Viewport.HEADLESS, |
| action="store_const", |
| help=("Start the browser in headless if supported. " |
| "Equivalent to --viewport=headless.")) |
| |
| chrome_args = subparser.add_argument_group( |
| "Browsers Options: Chrome/Chromium", |
| "For convenience these arguments are directly are forwarded " |
| "directly to chrome. ") |
| chrome_args.add_argument( |
| "--js-flags", dest="js_flags", action="append", default=[]) |
| |
| doc_str = "See chrome's base/feature_list.h source file for more details" |
| chrome_args.add_argument( |
| "--enable-features", |
| help="Comma-separated list of enabled chrome features. " + doc_str, |
| default="") |
| chrome_args.add_argument( |
| "--disable-features", |
| help="Command-separated list of disabled chrome features. " + doc_str, |
| default="") |
| |
| field_trial_group = chrome_args.add_mutually_exclusive_group() |
| field_trial_group.add_argument( |
| "--enable-field-trial-config", |
| "--enable-field-trials", |
| default=None, |
| action="store_true", |
| help=("Use chrome's field-trial configs, " |
| "disabled by default by crossbench")) |
| field_trial_group.add_argument( |
| "--disable-field-trial-config", |
| "--disable-field-trials", |
| dest="enable_field_trial_config", |
| action="store_false", |
| help=("Explicitly disable field-trial configs." |
| "Off by default on official builds, " |
| "and disabled by default by crossbench.")) |
| |
| probe_group = subparser.add_argument_group("Probe Options", "") |
| probe_config_group = probe_group.add_mutually_exclusive_group() |
| probe_config_group.add_argument( |
| "--probe", |
| action="append", |
| type=ProbeConfig.parse, |
| default=[], |
| help=( |
| "Enable general purpose probes to measure data on all cb.stories. " |
| "This argument can be specified multiple times to add more probes. " |
| "Use inline hjson (e.g. --probe=\"$NAME{$CONFIG}\") " |
| "to configure probes. " |
| "Individual probe configs can be specified in files as well: " |
| "--probe='path/to/config.hjson'." |
| "Use 'describe probes' or 'describe probe $NAME' for probe " |
| "configuration details." |
| "Cannot be used together with --probe-config." |
| f"\n\nChoices: {', '.join(PROBE_LOOKUP.keys())}")) |
| probe_config_group.add_argument( |
| "--probe-config", |
| type=PathParser.hjson_file_path, |
| default=benchmark_cls.default_probe_config_path(), |
| help=("Browser configuration.json file. " |
| "Use this config file to specify more complex Probe settings." |
| "See config/doc/probe.config.hjson on how to set up a complex " |
| "configuration file. " |
| "Cannot be used together with --probe.")) |
| subparser.set_defaults( |
| subcommand_fn=self.benchmark_subcommand, benchmark_cls=benchmark_cls) |
| self._add_verbosity_argument(subparser) |
| subparser.add_argument("other_browser_args", nargs="*") |
| |
| def benchmark_subcommand(self, args: argparse.Namespace) -> None: |
| benchmark = None |
| runner = None |
| self._benchmark_subcommand_helper(args) |
| try: |
| self._benchmark_subcommand_process_args(args) |
| benchmark = self._get_benchmark(args) |
| with tempfile.TemporaryDirectory(prefix="crossbench") as tmp_dirname: |
| if args.dry_run: |
| args.out_dir = pth.LocalPath(tmp_dirname) / "results" |
| args.browser = self._get_browsers(args) |
| probes = self._get_probes(args) |
| env_config = self._get_env_config(args) |
| env_validation_mode = self._get_env_validation_mode(args) |
| timing = self._get_timing(args) |
| runner = self._get_runner(args, benchmark, env_config, |
| env_validation_mode, timing) |
| |
| # We prevent running multiple stories in repetition OR if multiple |
| # browsers are open when 'power' probes are used since it might distort |
| # the data. |
| if len(args.browser) > 1 or args.repetitions > 1: |
| probe_names = [probe.name for probe in probes if probe.BATTERY_ONLY] |
| if probe_names: |
| names_str = ",".join(probe_names) |
| raise argparse.ArgumentTypeError( |
| f"Cannot use [{names_str}] probe(s) " |
| "with repeat > 1 and/or with multiple browsers. We need to " |
| "always start at the same battery level, and by running " |
| "stories on multiple browsers or multiples time will create " |
| "erroneous data.") |
| |
| for probe in probes: |
| runner.attach_probe(probe, matching_browser_only=True) |
| |
| self._run_benchmark(args, runner) |
| except KeyboardInterrupt: |
| sys.exit(2) |
| except LateArgumentError as e: |
| if args.throw: |
| raise |
| self.handle_late_argument_error(e) |
| except Exception as e: # pylint: disable=broad-except |
| if args.throw: |
| raise |
| self._log_benchmark_subcommand_failure(benchmark, runner, e) |
| sys.exit(3) |
| |
| def _benchmark_subcommand_helper(self, args: argparse.Namespace) -> None: |
| """Handle common subcommand mistakes that are not easily implementable |
| with argparse. |
| run: => just run the benchmark |
| help => use --help |
| describe => use describe benchmark NAME |
| """ |
| if not args.other_browser_args: |
| return |
| maybe_command = args.other_browser_args[0] |
| if maybe_command == "run": |
| args.other_browser_args.pop() |
| return |
| if maybe_command == "help": |
| self._subparsers[args.benchmark_cls].print_help() |
| sys.exit(0) |
| if maybe_command == "describe": |
| logging.warning("See `describe benchmark %s` for more options", |
| args.benchmark_cls.NAME) |
| # Patch args to simulate: describe benchmark BENCHMARK_NAME |
| args.category = "benchmarks" |
| args.filter = args.benchmark_cls.NAME |
| args.json = False |
| self.describe_subcommand(args) |
| sys.exit(0) |
| |
| def _process_network_args(self, args) -> None: |
| # The order of preference of flags is as follows: |
| # Explicitly specified network config > explicitly specified network > |
| # benchmark-specific network config > default network. |
| if network_config := args.network_config: |
| args.network = network_config |
| elif args.network: |
| pass |
| elif network_config := args.benchmark_cls.default_network_config_path(): |
| args.network = network_config |
| else: |
| args.network = NetworkConfig.default() |
| |
| def _benchmark_subcommand_process_args(self, args) -> None: |
| if args.config: |
| self._process_config_args(args) |
| else: |
| # We keep separate *_config args so we can throw in case they conflict |
| # with --config. Since we don't use argparse's dest, we have to manually |
| # copy the args.*_config back. |
| self._process_network_args(args) |
| |
| def _process_config_args(self, args) -> None: |
| if args.env_config: |
| raise argparse.ArgumentTypeError( |
| "--config cannot be used together with --env-config") |
| if args.network_config: |
| raise argparse.ArgumentTypeError( |
| "--config cannot be used together with --network-config") |
| if args.browser_config: |
| raise argparse.ArgumentTypeError( |
| "--config cannot be used together with --browser-config") |
| if args.probe_config: |
| raise argparse.ArgumentTypeError( |
| "--config cannot be used together with --probe-config") |
| |
| config_file = args.config |
| config_data = ObjectParser.hjson_file(config_file) |
| found_any_config = False |
| |
| if config_data.get("env"): |
| args.env_config = parse_env_config_file(config_file) |
| found_any_config = True |
| else: |
| logging.warning("Skipping env config: no 'env' property in %s", |
| config_file) |
| |
| if network_config_data := config_data.get("network"): |
| # TODO: migrate all --config helper to this format |
| args.network = NetworkConfig.parse(network_config_data) |
| found_any_config = True |
| else: |
| logging.warning("Skipping network config: no 'network' property in %s", |
| config_file) |
| if not args.network: |
| args.network = NetworkConfig.default() |
| |
| if config_data.get("browsers"): |
| args.browser_config = config_file |
| found_any_config = True |
| else: |
| logging.warning("Skipping browsers config: No 'browsers' property in %s", |
| config_file) |
| |
| if config_data.get("probes"): |
| args.probe_config = config_file |
| found_any_config = True |
| else: |
| logging.warning("Skipping probes config: no 'probes' property in %s", |
| config_file) |
| |
| if not found_any_config: |
| raise argparse.ArgumentTypeError( |
| f"--config: config file has no config properties {config_file}") |
| |
| def _log_benchmark_subcommand_failure(self, benchmark: Optional[Benchmark], |
| runner: Optional[Runner], |
| e: Exception) -> None: |
| logging.debug(e) |
| logging.error("") |
| logging.error("#" * 80) |
| logging.error("SUBCOMMAND UNSUCCESSFUL got %s:", e.__class__.__name__) |
| logging.error("-" * 80) |
| self._log_benchmark_subcommand_exception(e) |
| logging.error("-" * 80) |
| if benchmark: |
| logging.error("Running '%s' was not successful:", benchmark.NAME) |
| logging.error( |
| "- Use --debug for very verbose output (equivalent to --throw -vvv)") |
| if runner and runner.runs: |
| self._log_runner_debug_hints(runner) |
| else: |
| logging.error("- Check %s.json detailed backtraces", ErrorsProbe.NAME) |
| logging.error("#" * 80) |
| sys.exit(3) |
| |
| def _log_benchmark_subcommand_exception(self, e: Exception) -> None: |
| message = str(e) |
| if message: |
| logging.error(message) |
| return |
| if isinstance(e, AssertionError): |
| self._log_assertion_error_statement(e) |
| |
| def _log_assertion_error_statement(self, e: AssertionError) -> None: |
| _, exception, tb = sys.exc_info() |
| if exception is not e: |
| return |
| tb_info = traceback.extract_tb(tb) |
| filename, line, _, text = tb_info[-1] |
| logging.info("%s:%s: %s", filename, line, text) |
| |
| def _log_runner_debug_hints(self, runner: Runner) -> None: |
| failed_runs = [run for run in runner.runs if not run.is_success] |
| if not failed_runs: |
| return |
| candidates: List[pth.LocalPath] = [ |
| *runner.out_dir.glob(f"{ErrorsProbe.NAME}*"), |
| ] |
| for failed_run in failed_runs: |
| candidates.extend(failed_run.out_dir.glob(f"{ErrorsProbe.NAME}*")) |
| candidates.extend(failed_run.out_dir.glob("*.log")) |
| |
| failed_run = failed_runs[0] |
| logging.error("- Check log outputs (1 of %d failed runs):", |
| len(failed_runs)) |
| limit = 3 |
| for log_file in candidates[:limit]: |
| try: |
| log_file = log_file.relative_to(pth.LocalPath.cwd()) |
| except Exception as e: # pylint: disable=broad-except |
| logging.debug("Could not create relative log_file: %s", e) |
| logging.error(" - %s", log_file) |
| if (pending := len(candidates) - limit) > 0: |
| logging.error(" - ... and %d more interesting %s.json or *.log files", |
| pending, ErrorsProbe.NAME) |
| |
| def _run_benchmark(self, args: argparse.Namespace, runner: Runner) -> None: |
| try: |
| runner.run(is_dry_run=args.dry_run) |
| logging.info("") |
| self._log_results(args, runner, is_success=runner.is_success) |
| except: # pylint: disable=broad-except |
| self._log_results(args, runner, is_success=False) |
| raise |
| finally: |
| self._update_symlinks(args, runner) |
| |
| def _update_symlinks(self, args: argparse.Namespace, runner: Runner) -> None: |
| if not args.create_symlinks: |
| logging.debug("Symlink disabled by command line option") |
| return |
| if plt.PLATFORM.is_win: |
| logging.debug("Skipping session_dir symlink on windows.") |
| return |
| if not args.out_dir and runner.out_dir.exists(): |
| self._update_default_results_symlinks(runner) |
| self._create_runs_results_symlinks(runner) |
| |
| def _update_default_results_symlinks(self, runner: Runner) -> None: |
| results_root = runner.out_dir.parent |
| latest_link = results_root / "latest" |
| if latest_link.is_symlink(): |
| latest_link.unlink() |
| if not latest_link.exists(): |
| latest_link.symlink_to( |
| runner.out_dir.relative_to(results_root), target_is_directory=True) |
| else: |
| logging.error("Could not create %s", latest_link) |
| |
| def _create_runs_results_symlinks(self, runner: Runner) -> None: |
| results_root = runner.out_dir.parent |
| runs: Tuple[Run, ...] = runner.all_runs |
| if not runs: |
| logging.debug("Skip creating result symlinks in '%s': no runs produced.", |
| results_root) |
| return |
| out_dir = runner.out_dir |
| first_run_dir = out_dir / "first_run" |
| last_run_dir = out_dir / "last_run" |
| if first_run_dir.exists(): |
| logging.error("Cannot create first_run symlink: %s", first_run_dir) |
| else: |
| first_run_dir.symlink_to(runs[0].out_dir.relative_to(out_dir)) |
| if last_run_dir.exists(): |
| logging.error("Cannot create last_run symlink: %s", last_run_dir) |
| else: |
| last_run_dir.symlink_to(runs[-1].out_dir.relative_to(out_dir)) |
| |
| runs_dir = out_dir / "runs" |
| runs_dir.mkdir() |
| for run in runs: |
| if not run.out_dir.exists(): |
| continue |
| relative = pth.LocalPath("..") / run.out_dir.relative_to(out_dir) |
| (runs_dir / str(run.index)).symlink_to(relative) |
| |
| sessions_dir = out_dir / "sessions" |
| sessions_dir.mkdir() |
| for session in set(run.browser_session for run in runs): |
| relative = pth.LocalPath("..") / session.path.relative_to(out_dir) |
| (sessions_dir / str(session.index)).symlink_to(relative) |
| |
| def _log_results(self, args: argparse.Namespace, runner: Runner, |
| is_success: bool) -> None: |
| logging.info("=" * 80) |
| if is_success: |
| logging.critical("RESULTS: %s", runner.out_dir) |
| else: |
| logging.critical("RESULTS (maybe incomplete/broken): %s", runner.out_dir) |
| logging.info("=" * 80) |
| if not runner.has_browser_group: |
| logging.debug("No browser group in %s", runner) |
| return |
| browser_group = runner.browser_group |
| for probe in runner.probes: |
| try: |
| probe.log_browsers_result(browser_group) |
| except Exception as e: # pylint: disable=broad-except |
| if args.throw: |
| raise |
| logging.warning("log_result_summary failed: %s", e) |
| |
| def _get_browsers(self, args: argparse.Namespace) -> Sequence[Browser]: |
| # TODO: move browser instance create to separate method. |
| # TODO: move --browser-config parsing to BrowserVariantsConfig |
| args.browser_config = BrowserVariantsConfig.from_cli_args(args) |
| return args.browser_config.variants |
| |
| def _get_probes(self, args: argparse.Namespace) -> Sequence[Probe]: |
| # TODO: move probe creation to separate method |
| # TODO: move --probe-config parsing to ProbeListConfig |
| args.probe_config = ProbeListConfig.from_cli_args(args) |
| return args.probe_config.probes |
| |
| def _get_benchmark(self, args: argparse.Namespace) -> Benchmark: |
| benchmark_cls = self._get_benchmark_cls(args) |
| assert (issubclass(benchmark_cls, Benchmark)), ( |
| f"benchmark_cls={benchmark_cls} is not subclass of Runner") |
| return benchmark_cls.from_cli_args(args) |
| |
| def _get_benchmark_cls(self, args: argparse.Namespace) -> Type[Benchmark]: |
| return args.benchmark_cls |
| |
| def _get_env_validation_mode(self, |
| args: argparse.Namespace) -> ValidationMode: |
| return args.env_validation |
| |
| def _get_env_config(self, args: argparse.Namespace) -> HostEnvironmentConfig: |
| # TODO: move env_config to args.env and use ConfigObject |
| if args.env: |
| return args.env |
| if args.env_config: |
| return args.env_config |
| return HostEnvironmentConfig() |
| |
| def _get_timing(self, args: argparse.Namespace) -> Timing: |
| timeout_unit: dt.timedelta = args.timeout_unit or args.time_unit |
| return Timing(args.cool_down_time, args.time_unit, timeout_unit, |
| args.run_timeout, args.start_delay, args.stop_delay) |
| |
| def _get_runner(self, args: argparse.Namespace, benchmark: Benchmark, |
| env_config: HostEnvironmentConfig, |
| env_validation_mode: ValidationMode, |
| timing: Timing) -> Runner: |
| runner_kwargs = self.RUNNER_CLS.kwargs_from_cli(args) |
| return self.RUNNER_CLS( |
| benchmark=benchmark, |
| env_config=env_config, |
| env_validation_mode=env_validation_mode, |
| timing=timing, |
| **runner_kwargs) |
| |
| def run(self, argv: Sequence[str]) -> None: |
| self._init_logging(argv) |
| unprocessed_argv: List[str] = [] |
| try: |
| # Manually check for unprocessed_argv to print nicer error messages. |
| self.args, unprocessed_argv = self.parser.parse_known_args(argv) |
| except argparse.ArgumentError as e: |
| # args is not set at this point, as parsing might have failed before |
| # handling --throw or --debug. |
| if "--throw" in argv or "--debug" in argv: |
| raise e |
| self.error(str(e)) |
| if unprocessed_argv: |
| self.error(f"unrecognized arguments: {unprocessed_argv}\n" |
| f"Use `{self.parser.prog} {self.args.subcommand} --help` " |
| "for more details.") |
| # Properly initialize logging after having parsed all args |
| self._setup_logging() |
| try: |
| self.args.subcommand_fn(self.args) |
| finally: |
| self._teardown_logging() |
| |
| def handle_late_argument_error(self, e: LateArgumentError) -> None: |
| self.error(f"error argument {e.flag}: {e.message}") |
| |
| def error(self, message: str) -> None: |
| parser: CrossBenchArgumentParser = self.parser |
| # Try to use the subparser to print nicer usage help on errors. |
| # ArgumentParser tends to default to the toplevel parser instead of the |
| # current subcommand, which in turn prints the wrong usage text. |
| subcommand: str = getattr(self.args, "subcommand", "") |
| if subcommand == "describe": |
| parser = self.describe_parser |
| else: |
| maybe_benchmark_cls = getattr(self.args, "benchmark_cls", None) |
| if maybe_benchmark_cls: |
| parser = self._subparsers[maybe_benchmark_cls] |
| if subcommand: |
| parser.fail(f"{subcommand}: {message}") |
| else: |
| parser.fail(message) |
| |
| def _init_logging(self, argv: Sequence[str]) -> None: |
| assert self._console_handler is None |
| if not self._enable_logging: |
| logging.getLogger().setLevel(logging.CRITICAL) |
| return |
| self._console_handler = logging.StreamHandler(sys.stderr) |
| self._console_handler.addFilter(logging.Filter("root")) |
| self._console_handler.setLevel(logging.INFO) |
| logging.getLogger().setLevel(logging.INFO) |
| logging.getLogger().addHandler(self._console_handler) |
| |
| # Manually extract values to allow logging for failing arguments. |
| if "-v" in argv or "-vv" in argv or "-vvv" in argv: |
| self._console_handler.setLevel(logging.DEBUG) |
| logging.getLogger().setLevel(logging.DEBUG) |
| # TODO: move to ui helpers |
| ui.COLOR_LOGGING = "--no-color" not in argv |
| if ui.COLOR_LOGGING: |
| self._console_handler.setFormatter(ui.ColoredLogFormatter()) |
| |
| def _setup_logging(self) -> None: |
| if not self._enable_logging: |
| return |
| assert self._console_handler |
| if self.args.verbosity == -1: |
| self._console_handler.setLevel(logging.ERROR) |
| elif self.args.verbosity == 0: |
| self._console_handler.setLevel(logging.INFO) |
| elif self.args.verbosity >= 1: |
| self._console_handler.setLevel(logging.DEBUG) |
| logging.getLogger().setLevel(logging.DEBUG) |
| ui.COLOR_LOGGING = self.args.color |
| if ui.COLOR_LOGGING: |
| self._console_handler.setFormatter(ui.ColoredLogFormatter()) |
| else: |
| self._console_handler.setFormatter(None) |
| |
| def _teardown_logging(self) -> None: |
| if not self._enable_logging: |
| assert self._console_handler is None |
| return |
| assert self._console_handler |
| self._console_handler.flush() |
| logging.getLogger().removeHandler(self._console_handler) |
| self._console_handler = None |