| # Copyright 2023 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 |
| from typing import TYPE_CHECKING, Any, ClassVar, Mapping, Optional, Sequence, \ |
| Type |
| |
| from typing_extensions import override |
| |
| from crossbench.benchmarks.base import StoryFilter, SubStoryBenchmark |
| from crossbench.benchmarks.loading.config.pages import \ |
| DevToolsRecorderPagesConfig, ListPagesConfig, PageConfig, PagesConfig |
| from crossbench.benchmarks.loading.page.base import DEFAULT_DURATION, Page |
| from crossbench.benchmarks.loading.page.combined import CombinedPage |
| from crossbench.benchmarks.loading.page.interactive import InteractivePage |
| from crossbench.benchmarks.loading.page.live import PAGE_LIST, \ |
| PAGE_LIST_SMALL, PAGES, LivePage |
| from crossbench.benchmarks.loading.playback_controller import \ |
| PlaybackController |
| from crossbench.benchmarks.loading.tab_controller import TabController |
| from crossbench.parse import DurationParser, ObjectParser |
| |
| if TYPE_CHECKING: |
| from crossbench.action_runner.config import ActionRunnerConfig |
| from crossbench.cli.parser import CrossBenchArgumentParser |
| from crossbench.cli.types import Subparsers |
| from crossbench.stories.story import Story |
| |
| |
| class LoadingPageFilter(StoryFilter[Page]): |
| """ |
| Filter / create loading stories |
| |
| Syntax: |
| "name" Include LivePage with the given name from predefined list. |
| "name", 10 Include predefined page with given 10s timeout. |
| "http://..." Include custom page at the given URL with a default |
| timeout of 15 seconds. |
| "http://...", 12 Include custom page at the given URL with a 12s timeout |
| |
| These patterns can be combined: |
| ["http://foo.com", 5, "http://bar.co.jp", "amazon"] |
| """ |
| stories: Sequence[Page] |
| |
| @classmethod |
| @override |
| def add_cli_arguments( |
| cls, parser: argparse.ArgumentParser) -> argparse.ArgumentParser: |
| parser = super().add_cli_arguments(parser) |
| cls.add_page_config_parser(parser) |
| tab_group = parser.add_mutually_exclusive_group() |
| tab_group.add_argument( |
| "--single-tab", |
| dest="tabs", |
| const=TabController.single(), |
| default=TabController.default(), |
| action="store_const", |
| help="Open given urls in a single tab.") |
| tab_group.add_argument( |
| "--multiple-tab", |
| dest="tabs", |
| nargs="?", |
| type=TabController.parse, |
| const=TabController.multiple(), |
| help="Open given urls in separate tabs " |
| "(optional value for number of tabs for each url).") |
| tab_group.add_argument( |
| "--infinite-tab", |
| dest="tabs", |
| const=TabController.forever(), |
| action="store_const", |
| help="Open given urls in separate tabs infinitely.") |
| |
| playback_group = parser.add_mutually_exclusive_group() |
| playback_group.add_argument( |
| "--playback", |
| "--cycle", |
| type=PlaybackController.parse, |
| default=PlaybackController.default(), |
| help="Set limit on looping through/repeating the selected stories. " |
| "Default is once. " |
| "Valid values are: 'once', 'forever', number, time. " |
| "Cycle 10 times: '--playback=10x'. " |
| "Repeat for 1.5 hours: '--playback=1.5h'. " |
| "Run 5 times, once every 10 seconds: '--playback=\"5x every 10s\"'.") |
| playback_group.add_argument( |
| "--forever", |
| dest="playback", |
| const=PlaybackController.forever(), |
| action="store_const", |
| help="Equivalent to --playback=infinity") |
| |
| parser.add_argument( |
| "--about-blank-duration", |
| "--about-blank", |
| type=DurationParser.positive_or_zero_duration, |
| default=dt.timedelta(), |
| help="If non-zero, navigate to about:blank after every page.") |
| |
| block_modifier_group = parser.add_argument_group("Action Block Options") |
| block_modifier_group.add_argument( |
| "--skip-login", |
| dest="run_login", |
| default=True, |
| action="store_const", |
| const=False, |
| help="Skip the login block, useful for replaying " |
| "archive that filtered already all login requests " |
| "to hide potential secrets. " |
| "The login block is run by default.") |
| block_modifier_group.add_argument( |
| "--skip-setup", |
| dest="run_setup", |
| default=True, |
| action="store_const", |
| const=False, |
| help="Skip the setup block, useful for replaying " |
| "archive that filtered already all login requests " |
| "to hide potential secrets. " |
| "The setup block is run by default.") |
| |
| return parser |
| |
| @classmethod |
| def add_page_config_arguments(cls, group: argparse._ArgumentGroup) -> None: |
| group.add_argument( |
| "--page-config", |
| "--pages-config", |
| dest="pages_config", |
| type=PagesConfig.parse, |
| help="Stories we want to perform in the benchmark run following a " |
| "specified scenario. For a reference on how to build scenarios and " |
| "possible actions check config/doc/pages.config.hjson") |
| |
| @classmethod |
| def add_page_config_parser(cls, parser: argparse.ArgumentParser) -> None: |
| page_config_group = parser.add_mutually_exclusive_group() |
| # TODO: move --stories into mutually exclusive group as well |
| page_config_group.add_argument( |
| "--urls", |
| "--url", |
| dest="urls", |
| help="List of urls and durations to load: url,seconds,...") |
| cls.add_page_config_arguments(page_config_group) |
| page_config_group.add_argument( |
| "--url-file", |
| "--urls-file", |
| dest="pages_config", |
| type=ListPagesConfig.parse, |
| help=("List of urls and durations in a line-by-line file. " |
| "Each line has the same format as --url for a single Page.")) |
| page_config_group.add_argument( |
| "--devtools-recorder", |
| dest="pages_config", |
| type=DevToolsRecorderPagesConfig.parse, |
| help="Run a single story from a serialized DevTools recorder session. " |
| "See https://developer.chrome.com/docs/devtools/recorder/ " |
| "for more details.") |
| |
| @classmethod |
| @override |
| def kwargs_from_cli(cls, args: argparse.Namespace) -> dict[str, Any]: |
| kwargs = super().kwargs_from_cli(args) |
| kwargs["separate"] = args.separate |
| return kwargs |
| |
| @override |
| def process_all(self, patterns: Sequence[str]) -> None: |
| name_or_url_list = patterns |
| if len(name_or_url_list) == 1: |
| if name_or_url_list[0] == "all": |
| self.stories = self.all_stories() |
| return |
| if name_or_url_list[0] == "default": |
| self.stories = self.default_stories() |
| return |
| # Let the PageConfig handle the arg splitting again: |
| config = PagesConfig.parse(",".join(patterns)) |
| self.stories = self.stories_from_config(self.args, config) |
| |
| @classmethod |
| def all_stories(cls) -> tuple[Page, ...]: |
| return tuple(PAGE_LIST) |
| |
| @classmethod |
| def default_stories(cls) -> tuple[Page, ...]: |
| return PAGE_LIST_SMALL |
| |
| @classmethod |
| def stories_from_config(cls, args: argparse.Namespace, |
| config: PagesConfig) -> Sequence[Page]: |
| labels: set[str |
| | None] = {page_config.label for page_config in config.pages} |
| use_labels = len(labels) == len(config.pages) |
| |
| stories: list[Page] = [] |
| for page_config in config.pages: |
| stories.append(cls._story_from_config(args, page_config, use_labels)) |
| |
| if not use_labels: |
| # Double check that the urls are unique |
| urls: set[str] = {page_config.first_url for page_config in config.pages} |
| if len(urls) != len(config.pages): |
| raise argparse.ArgumentTypeError( |
| "Got non-unique story labels and urls.") |
| return stories |
| |
| @classmethod |
| def _story_from_config(cls, args: argparse.Namespace, config: PageConfig, |
| use_labels: bool) -> Page: |
| playback: PlaybackController = args.playback |
| tabs: TabController = args.tabs |
| if config.playback: |
| # TODO: support custom config playback |
| playback = config.playback |
| duration: dt.timedelta = config.duration |
| if config.label in PAGES: |
| page = PAGES[config.label] |
| duration = duration or page.duration |
| return LivePage(page.name, page.url, duration, playback, tabs, |
| args.about_blank_duration) |
| |
| label: str = config.any_label if use_labels else config.first_url |
| duration = duration or DEFAULT_DURATION |
| |
| if not config.blocks: |
| return LivePage(label, config.first_url, duration, playback, tabs, |
| args.about_blank_duration) |
| return InteractivePage( |
| name=label, |
| blocks=config.blocks, |
| login=config.login, |
| setup=config.setup, |
| teardown=config.teardown, |
| secrets=config.secrets, |
| playback=playback, |
| tabs=tabs, |
| about_blank_duration=args.about_blank_duration, |
| run_login=args.run_login, |
| run_setup=args.run_setup) |
| |
| @override |
| def create_stories(self, separate: bool) -> Sequence[Page]: |
| if not separate and len(self.stories) > 1: |
| combined_name = "_".join(page.name for page in self.stories) |
| args = self.args |
| self.stories = (CombinedPage(self.stories, combined_name, args.playback, |
| args.tabs),) |
| return self.stories |
| |
| |
| class LoadingBenchmark(SubStoryBenchmark): |
| """ |
| Benchmark runner for loading pages with complex interactions. |
| |
| Use --urls/--stories to either choose from an existing set of pages, or direct |
| URLs. After each page you can also specify a custom wait/load duration in |
| seconds. Multiple URLs/page names can be provided as a comma-separated list. |
| |
| Use --separate to load each page individually. |
| |
| Example: |
| --urls=amazon |
| --urls=http://cnn.com,10s |
| --urls=http://twitter.com,5s,http://cnn.com,10s |
| """ |
| NAME: ClassVar = "loading" |
| DEFAULT_STORY_CLS: ClassVar = Page |
| STORY_FILTER_CLS: ClassVar[Type[LoadingPageFilter]] = LoadingPageFilter |
| |
| @classmethod |
| @override |
| def add_cli_parser(cls, subparsers: Subparsers) -> CrossBenchArgumentParser: |
| parser = super().add_cli_parser(subparsers) |
| cls.STORY_FILTER_CLS.add_cli_arguments(parser) |
| return parser |
| |
| @classmethod |
| @override |
| def stories_from_cli_args(cls, args: argparse.Namespace) -> Sequence[Story]: |
| has_default_stories: bool = ( |
| args.stories and args.stories == LoadingPageFilter.DEFAULT_STORY_NAME) |
| if config := cls.get_pages_config(args): |
| # TODO: make stories and page_config mutually exclusive. |
| if not has_default_stories: |
| raise argparse.ArgumentTypeError( |
| f"Cannot specify --stories={repr(args.stories)} " |
| "with any other page config option.") |
| pages = cls.STORY_FILTER_CLS.stories_from_config(args, config) |
| if args.separate: |
| return pages |
| if len(pages) == 1: |
| return pages |
| return (CombinedPage(pages, "Page Scenarios - Combined", args.playback, |
| args.tabs),) |
| |
| if args.urls: |
| # TODO: make urls and stories mutually exclusive. |
| if not has_default_stories: |
| raise argparse.ArgumentTypeError( |
| "Cannot specify --urls and --stories at the same time.") |
| args.stories = args.urls |
| |
| # Fall back to story filter class. |
| return super().stories_from_cli_args(args) |
| |
| @classmethod |
| def get_pages_config(cls, |
| args: Optional[argparse.Namespace] = None |
| ) -> Optional[PagesConfig]: |
| if not args: |
| raise ValueError("Missing args") |
| if global_config := args.config: |
| # TODO: migrate --config to an already parsed hjson/json dict |
| config_file = global_config |
| config_data = ObjectParser.hjson_file(config_file) |
| if pages_config_dict := config_data.get("pages"): |
| if args.pages_config: |
| raise argparse.ArgumentTypeError( |
| "Conflicting arguments: " |
| "either specify a --config file without a 'pages' property " |
| "or remove the --page-config argument.") |
| # TODO: PagesConfig.parse_dict should be able to parse the inner dict. |
| return PagesConfig.parse_dict({"pages": pages_config_dict}) |
| return args.pages_config |
| |
| @classmethod |
| @override |
| def aliases(cls) -> tuple[str, ...]: |
| return ("load", "ld") |
| |
| @classmethod |
| @override |
| def describe_stories(cls) -> Mapping[str, str]: |
| result: dict[str, str] = {} |
| for story in cls.all_stories(): |
| story_help = story.help() |
| if story_help == story.name: |
| story_help = "" |
| result[story.name] = story_help |
| return result |
| |
| @classmethod |
| @override |
| def all_story_names(cls) -> Sequence[str]: |
| # TODO: Use StoryFilter for listing stories everywhere. |
| return sorted(story.name for story in cls.STORY_FILTER_CLS.all_stories()) |
| |
| def __init__( |
| self, |
| stories: Sequence[Page], |
| action_runner_config: Optional[ActionRunnerConfig] = None) -> None: |
| for story in stories: |
| assert isinstance(story, Page) |
| super().__init__(stories, action_runner_config) |