1# Copyright 2023 The Chromium Authors 2# Use of this source code is governed by a BSD-style license that can be 3# found in the LICENSE file. 4 5from __future__ import annotations 6 7import argparse 8import datetime as dt 9import logging 10from typing import (TYPE_CHECKING, Any, Dict, List, Optional, Sequence, Tuple, 11 Type) 12 13from crossbench.action_runner.basic_action_runner import BasicActionRunner 14from crossbench.action_runner.config import ActionRunnerConfig 15from crossbench.benchmarks.base import StoryFilter, SubStoryBenchmark 16from crossbench.benchmarks.loading.config.pages import ( 17 DevToolsRecorderPagesConfig, ListPagesConfig, PageConfig, PagesConfig) 18from crossbench.benchmarks.loading.page.base import DEFAULT_DURATION, Page 19from crossbench.benchmarks.loading.page.combined import CombinedPage 20from crossbench.benchmarks.loading.page.interactive import InteractivePage 21from crossbench.benchmarks.loading.page.live import (PAGE_LIST, 22 PAGE_LIST_SMALL, PAGES, 23 LivePage) 24from crossbench.benchmarks.loading.playback_controller import \ 25 PlaybackController 26from crossbench.benchmarks.loading.tab_controller import TabController 27from crossbench.parse import DurationParser, ObjectParser 28 29if TYPE_CHECKING: 30 from crossbench.action_runner.base import ActionRunner 31 from crossbench.cli.parser import CrossBenchArgumentParser 32 from crossbench.stories.story import Story 33 34 35class LoadingPageFilter(StoryFilter[Page]): 36 """ 37 Filter / create loading stories 38 39 Syntax: 40 "name" Include LivePage with the given name from predefined list. 41 "name", 10 Include predefined page with given 10s timeout. 42 "http://..." Include custom page at the given URL with a default 43 timeout of 15 seconds. 44 "http://...", 12 Include custom page at the given URL with a 12s timeout 45 46 These patterns can be combined: 47 ["http://foo.com", 5, "http://bar.co.jp", "amazon"] 48 """ 49 stories: Sequence[Page] 50 51 @classmethod 52 def add_cli_parser( 53 cls, parser: argparse.ArgumentParser) -> argparse.ArgumentParser: 54 parser = super().add_cli_parser(parser) 55 cls.add_page_config_parser(parser) 56 tab_group = parser.add_mutually_exclusive_group() 57 tab_group.add_argument( 58 "--single-tab", 59 dest="tabs", 60 const=TabController.single(), 61 default=TabController.default(), 62 action="store_const", 63 help="Open given urls in a single tab.") 64 tab_group.add_argument( 65 "--multiple-tab", 66 dest="tabs", 67 nargs="?", 68 type=TabController.parse, 69 const=TabController.multiple(), 70 help="Open given urls in separate tabs " 71 "(optional value for number of tabs for each url).") 72 tab_group.add_argument( 73 "--infinite-tab", 74 dest="tabs", 75 const=TabController.forever(), 76 action="store_const", 77 help="Open given urls in separate tabs infinitely.") 78 79 playback_group = parser.add_mutually_exclusive_group() 80 playback_group.add_argument( 81 "--playback", 82 "--cycle", 83 type=PlaybackController.parse, 84 default=PlaybackController.default(), 85 help="Set limit on looping through/repeating the selected stories. " 86 "Default is once." 87 "Valid values are: 'once', 'forever', number, time. " 88 "Cycle 10 times: '--playback=10x'. " 89 "Repeat for 1.5 hours: '--playback=1.5h'.") 90 playback_group.add_argument( 91 "--forever", 92 dest="playback", 93 const=PlaybackController.forever(), 94 action="store_const", 95 help="Equivalent to --playback=infinity") 96 97 parser.add_argument( 98 "--about-blank-duration", 99 "--about-blank", 100 type=DurationParser.positive_or_zero_duration, 101 default=dt.timedelta(), 102 help="If non-zero, navigate to about:blank after every page.") 103 104 block_modifier_group = parser.add_argument_group("Action Block Options") 105 block_modifier_group.add_argument( 106 "--skip-login", 107 dest="run_login", 108 default=True, 109 action="store_const", 110 const=False, 111 help="Skip the login block, useful for replaying " 112 "archive that filtered already all login requests " 113 "to hide potential secrets. " 114 "The login block is run by default.") 115 block_modifier_group.add_argument( 116 "--skip-setup", 117 dest="run_setup", 118 default=True, 119 action="store_const", 120 const=False, 121 help="Skip the setup block, useful for replaying " 122 "archive that filtered already all login requests " 123 "to hide potential secrets. " 124 "The setup block is run by default.") 125 126 return parser 127 128 @classmethod 129 def add_page_config_parser(cls, parser) -> None: 130 page_config_group = parser.add_mutually_exclusive_group() 131 # TODO: move --stories into mutually exclusive group as well 132 page_config_group.add_argument( 133 "--urls", 134 "--url", 135 dest="urls", 136 help="List of urls and durations to load: url,seconds,...") 137 page_config_group.add_argument( 138 "--page-config", 139 "--pages-config", 140 dest="pages_config", 141 type=PagesConfig.parse, 142 help="Stories we want to perform in the benchmark run following a" 143 "specified scenario. For a reference on how to build scenarios and" 144 "possible actions check config/doc/pages.config.hjson") 145 page_config_group.add_argument( 146 "--url-file", 147 "--urls-file", 148 dest="pages_config", 149 type=ListPagesConfig.parse, 150 help=("List of urls and durations in a line-by-line file. " 151 "Each line has the same format as --url for a single Page.")) 152 page_config_group.add_argument( 153 "--devtools-recorder", 154 dest="pages_config", 155 type=DevToolsRecorderPagesConfig.parse, 156 help="Run a single story from a serialized DevTools recorder session. " 157 "See https://developer.chrome.com/docs/devtools/recorder/ " 158 "for more details.") 159 160 @classmethod 161 def kwargs_from_cli(cls, args: argparse.Namespace) -> Dict[str, Any]: 162 kwargs = super().kwargs_from_cli(args) 163 kwargs["separate"] = args.separate 164 kwargs["args"] = args 165 return kwargs 166 167 def __init__(self, 168 story_cls: Type[Page], 169 patterns: Sequence[str], 170 args: argparse.Namespace, 171 separate: bool = True) -> None: 172 self._args: argparse.Namespace = args 173 super().__init__(story_cls, patterns, separate) 174 175 def process_all(self, patterns: Sequence[str]) -> None: 176 name_or_url_list = patterns 177 if len(name_or_url_list) == 1: 178 if name_or_url_list[0] == "all": 179 self.stories = self.all_stories() 180 return 181 if name_or_url_list[0] == "default": 182 self.stories = self.default_stories() 183 return 184 # Let the PageConfig handle the arg splitting again: 185 config = PagesConfig.parse(",".join(patterns)) 186 self.stories = self.stories_from_config(self._args, config) 187 188 @classmethod 189 def all_stories(cls) -> Tuple[Page, ...]: 190 return tuple(PAGE_LIST) 191 192 @classmethod 193 def default_stories(cls) -> Tuple[Page, ...]: 194 return PAGE_LIST_SMALL 195 196 @classmethod 197 def stories_from_config(cls, args: argparse.Namespace, 198 config: PagesConfig) -> Sequence[Page]: 199 labels = set(page_config.label for page_config in config.pages) 200 use_labels = len(labels) == len(config.pages) 201 202 stories: List[Page] = [] 203 for page_config in config.pages: 204 stories.append(cls._story_from_config(args, page_config, use_labels)) 205 206 if use_labels: 207 # Double check that the urls are unique 208 urls = set(page_config.first_url for page_config in config.pages) 209 if len(urls) != len(config.pages): 210 raise argparse.ArgumentTypeError( 211 "Got non-unique story labels and urls.") 212 return stories 213 214 @classmethod 215 def _story_from_config(cls, args: argparse.Namespace, config: PageConfig, 216 use_labels: bool) -> Page: 217 playback: PlaybackController = args.playback 218 tabs: TabController = args.tabs 219 if config.playback: 220 # TODO: support custom config playback 221 playback = config.playback 222 duration: dt.timedelta = config.duration 223 if config.label in PAGES: 224 page = PAGES[config.label] 225 duration = duration or page.duration 226 return LivePage(page.name, page.url, duration, playback, tabs, 227 args.about_blank_duration) 228 229 label: str = config.any_label if use_labels else config.first_url 230 duration = duration or DEFAULT_DURATION 231 232 if not config.blocks: 233 return LivePage(label, config.first_url, duration, playback, tabs, 234 args.about_blank_duration) 235 return InteractivePage(label, config.blocks, config.setup, config.login, 236 config.secrets.as_dict(), playback, tabs, 237 args.about_blank_duration, args.run_login, 238 args.run_setup) 239 240 def create_stories(self, separate: bool) -> Sequence[Page]: 241 logging.info("SELECTED STORIES: %s", str(list(map(str, self.stories)))) 242 if not separate and len(self.stories) > 1: 243 combined_name = "_".join(page.name for page in self.stories) 244 self.stories = (CombinedPage(self.stories, combined_name, 245 self._args.playback, self._args.tabs),) 246 return self.stories 247 248 249class PageLoadBenchmark(SubStoryBenchmark): 250 """ 251 Benchmark runner for loading pages. 252 253 Use --urls/--stories to either choose from an existing set of pages, or direct 254 URLs. After each page you can also specify a custom wait/load duration in 255 seconds. Multiple URLs/page names can be provided as a comma-separated list. 256 257 Use --separate to load each page individually. 258 259 Example: 260 --urls=amazon 261 --urls=http://cnn.com,10s 262 --urls=http://twitter.com,5s,http://cnn.com,10s 263 """ 264 NAME = "loading" 265 DEFAULT_STORY_CLS = Page 266 STORY_FILTER_CLS = LoadingPageFilter 267 268 @classmethod 269 def add_cli_parser( 270 cls, subparsers: argparse.ArgumentParser, aliases: Sequence[str] = () 271 ) -> CrossBenchArgumentParser: 272 parser = super().add_cli_parser(subparsers, aliases) 273 cls.STORY_FILTER_CLS.add_cli_parser(parser) 274 275 parser.add_argument( 276 "--action-runner", 277 type=ActionRunnerConfig.parse, 278 help="Set the action runner for interactive pages.") 279 return parser 280 281 @classmethod 282 def requires_separate(cls, args: argparse.Namespace) -> bool: 283 return args.separate 284 285 @classmethod 286 def stories_from_cli_args(cls, args: argparse.Namespace) -> Sequence[Story]: 287 has_default_stories: bool = args.stories and args.stories == "default" 288 if config := cls.get_pages_config(args): 289 # TODO: make stories and page_config mutually exclusive. 290 if not has_default_stories: 291 raise argparse.ArgumentTypeError( 292 f"Cannot specify --stories={repr(args.stories)} " 293 "with any other page config option.") 294 pages = LoadingPageFilter.stories_from_config(args, config) 295 if cls.requires_separate(args): 296 return pages 297 if len(pages) == 1: 298 return pages 299 return (CombinedPage(pages, "Page Scenarios - Combined", args.playback, 300 args.tabs),) 301 302 if args.urls: 303 # TODO: make urls and stories mutually exclusive. 304 if not has_default_stories: 305 raise argparse.ArgumentTypeError( 306 "Cannot specify --urls and --stories at the same time.") 307 args.stories = args.urls 308 309 # Fall back to story filter class. 310 return super().stories_from_cli_args(args) 311 312 @classmethod 313 def get_pages_config(cls, args: argparse.Namespace) -> Optional[PagesConfig]: 314 if global_config := args.config: 315 # TODO: migrate --config to an already parsed hjson/json dict 316 config_file = global_config 317 config_data = ObjectParser.hjson_file(config_file) 318 if pages_config_dict := config_data.get("pages"): 319 if args.pages_config: 320 raise argparse.ArgumentTypeError( 321 "Conflicting arguments: " 322 "either specify a --config file without a 'pages' property " 323 "or remove the --page-config argument.") 324 # TODO: PagesConfig.parse_dict should be able to parse the inner dict. 325 return PagesConfig.parse_dict({"pages": pages_config_dict}) 326 return args.pages_config 327 328 @classmethod 329 def aliases(cls) -> Tuple[str, ...]: 330 return ("load", "ld") 331 332 @classmethod 333 def kwargs_from_cli(cls, args: argparse.Namespace) -> Dict[str, Any]: 334 kwargs = super().kwargs_from_cli(args) 335 kwargs["action_runner"] = args.action_runner 336 return kwargs 337 338 @classmethod 339 def all_story_names(cls) -> Sequence[str]: 340 return sorted(LivePage.all_story_names()) 341 342 def __init__(self, 343 stories: Sequence[Page], 344 action_runner: Optional[ActionRunner] = None) -> None: 345 self._action_runner = action_runner or BasicActionRunner() 346 for story in stories: 347 assert isinstance(story, Page) 348 super().__init__(stories) 349 350 @property 351 def action_runner(self) -> ActionRunner: 352 return self._action_runner 353