blob: 5bffe8f7f813fe1c13275f0ba296a6ab9eb9e601 [file]
# 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)