• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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