blob: 8fadd8b3fcd48366f249f788c06f667afb72785c [file] [log] [blame]
# 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 abc
import datetime as dt
import json
import logging
from typing import (TYPE_CHECKING, Any, Dict, List, Optional, Sequence, Tuple,
Type)
from crossbench import helper
from crossbench.benchmarks.base import (BenchmarkProbeMixin, PressBenchmark,
PressBenchmarkStoryFilter)
from crossbench.parse import NumberParser
from crossbench.probes.helper import Flatten
from crossbench.probes.json import JsonResultProbe
from crossbench.probes.metric import Metric, MetricsMerger
from crossbench.probes.results import ProbeResult, ProbeResultDict
from crossbench.stories.press_benchmark import PressBenchmarkStory
if TYPE_CHECKING:
import argparse
from crossbench.path import LocalPath
from crossbench.runner.actions import Actions
from crossbench.runner.groups.browsers import BrowsersRunGroup
from crossbench.runner.groups.stories import StoriesRunGroup
from crossbench.runner.run import Run
from crossbench.types import Json
def _probe_remove_tests_segments(path: Tuple[str, ...]) -> str:
return "/".join(segment for segment in path if segment != "tests")
class SpeedometerProbe(
BenchmarkProbeMixin, JsonResultProbe, metaclass=abc.ABCMeta):
"""
Speedometer-specific probe (compatible with v2.X and v3.X).
Extracts all speedometer times and scores.
"""
JS: str = "return window.suiteValues;"
SORT_KEYS: bool = False
def to_json(self, actions: Actions) -> Json:
return actions.js(self.JS)
def flatten_json_data(self, json_data: Any) -> Json:
# json_data may contain multiple iterations, merge those first
assert isinstance(json_data, list), f"Expected list got {type(json_data)}"
merged = MetricsMerger(
json_data, key_fn=_probe_remove_tests_segments).to_json(
value_fn=lambda values: values.geomean, sort=self.SORT_KEYS)
return Flatten(merged, sort=self.SORT_KEYS).data
def merge_stories(self, group: StoriesRunGroup) -> ProbeResult:
merged = MetricsMerger.merge_json_list(
repetitions_group.results[self].json
for repetitions_group in group.repetitions_groups)
return self.write_group_result(group, merged)
def merge_browsers(self, group: BrowsersRunGroup) -> ProbeResult:
return self.merge_browsers_json_list(group).merge(
self.merge_browsers_csv_list(group))
def log_run_result(self, run: Run) -> None:
self._log_result(run.results, single_result=True)
def log_browsers_result(self, group: BrowsersRunGroup) -> None:
self._log_result(group.results, single_result=False)
def _log_result(self, result_dict: ProbeResultDict,
single_result: bool) -> None:
if self not in result_dict:
return
results_json: LocalPath = result_dict[self].json
logging.info("-" * 80)
logging.critical("Speedometer results:")
if not single_result:
logging.critical(" %s", result_dict[self].csv)
logging.info("- " * 40)
with results_json.open(encoding="utf-8") as f:
data = json.load(f)
if single_result:
score = data.get("score") or data["Score"]
logging.critical("Score %s", score)
else:
self._log_result_metrics(data)
def _extract_result_metrics_table(self, metrics: Dict[str, Any],
table: Dict[str, List[str]]) -> None:
for metric_key, metric in metrics.items():
if not self._valid_metric_key(metric_key):
continue
table[metric_key].append(
Metric.format(metric["average"], metric["stddev"]))
@abc.abstractmethod
def _valid_metric_key(self, metric_key: str) -> bool:
pass
class SpeedometerStory(PressBenchmarkStory, metaclass=abc.ABCMeta):
URL_LOCAL: str = "http://localhost:8000/"
DEFAULT_ITERATIONS: int = 10
def __init__(self,
substories: Sequence[str] = (),
iterations: Optional[int] = None,
url: Optional[str] = None):
self._iterations: int = iterations or self.DEFAULT_ITERATIONS
assert self.iterations >= 1, f"Invalid iterations count: '{iterations}'."
super().__init__(url=url, substories=substories)
@property
def iterations(self) -> int:
return self._iterations
@property
def substory_duration(self) -> dt.timedelta:
return self.iterations * dt.timedelta(seconds=0.4)
@property
def slow_duration(self) -> dt.timedelta:
"""Max duration that covers run-times on slow machines and/or
debug-mode browsers.
Making this number too large might cause needless wait times on broken
browsers/benchmarks.
"""
return dt.timedelta(seconds=60 * 20) + self.duration * 10
@property
def url_params(self) -> Dict[str, str]:
if self.iterations == self.DEFAULT_ITERATIONS:
return {}
return {"iterationCount": str(self.iterations)}
def setup(self, run: Run) -> None:
updated_url = self.get_run_url(run)
with run.actions("Setup") as actions:
actions.show_url(updated_url)
actions.wait_js_condition("return window.Suites !== undefined;", 0.5, 10)
self._setup_substories(actions)
self._setup_benchmark_client(actions)
actions.wait(0.5)
def get_run_url(self, run: Run) -> str:
url = super().get_run_url(run)
url = helper.update_url_query(url, self.url_params)
if url != self.url:
logging.info("CUSTOM URL: %s", url)
return url
def _setup_substories(self, actions: Actions) -> None:
if self._substories == self.SUBSTORIES:
return
actions.js(
"""
let substories = arguments[0];
Suites.forEach((suite) => {
suite.disabled = substories.indexOf(suite.name) === -1;
});""",
arguments=[self._substories])
def _setup_benchmark_client(self, actions: Actions) -> None:
actions.js("""
window.testDone = false;
window.suiteValues = [];
const client = window.benchmarkClient;
const clientCopy = {
didRunSuites: client.didRunSuites,
didFinishLastIteration: client.didFinishLastIteration,
};
client.didRunSuites = function(measuredValues, ...arguments) {
clientCopy.didRunSuites.call(this, measuredValues, ...arguments);
window.suiteValues.push(measuredValues);
};
client.didFinishLastIteration = function(...arguments) {
clientCopy.didFinishLastIteration.call(this, ...arguments);
window.testDone = true;
};""")
def run(self, run: Run) -> None:
with run.actions("Running") as actions:
actions.js("""
if (window.startTest) {
window.startTest();
} else {
// Interactive Runner fallback / old 3.0 fallback.
let startButton = document.getElementById("runSuites") ||
document.querySelector("start-tests-button") ||
document.querySelector(".buttons button");
startButton.click();
}
""")
actions.wait(self.fast_duration)
with run.actions("Waiting for completion") as actions:
actions.wait_js_condition("return window.testDone",
self.substory_duration, self.slow_duration)
ProbeClsTupleT = Tuple[Type[SpeedometerProbe], ...]
class SpeedometerBenchmarkStoryFilter(PressBenchmarkStoryFilter):
__doc__ = PressBenchmarkStoryFilter.__doc__
@classmethod
def add_cli_parser(
cls, parser: argparse.ArgumentParser) -> argparse.ArgumentParser:
parser = super().add_cli_parser(parser)
parser.add_argument(
"--iterations",
"--iteration-count",
default=SpeedometerStory.DEFAULT_ITERATIONS,
type=NumberParser.positive_int,
help="Number of iterations each Speedometer subtest is run "
"within the same session. \n"
"Note: --repetitions restarts the whole benchmark, --iterations runs "
"the same test tests n-times within the same session without the setup "
"overhead of starting up a whole new browser.")
return parser
@classmethod
def kwargs_from_cli(cls, args: argparse.Namespace) -> Dict[str, Any]:
kwargs = super().kwargs_from_cli(args)
kwargs["iterations"] = args.iterations
return kwargs
def __init__(self,
story_cls: Type[SpeedometerStory],
patterns: Sequence[str],
separate: bool = False,
url: Optional[str] = None,
iterations: Optional[int] = None):
self.iterations = iterations
assert issubclass(story_cls, SpeedometerStory)
super().__init__(story_cls, patterns, separate, url)
def create_stories_from_names(self, names: List[str],
separate: bool) -> Sequence[SpeedometerStory]:
return self.story_cls.from_names(
names, separate=separate, url=self.url, iterations=self.iterations)
class SpeedometerBenchmark(PressBenchmark, metaclass=abc.ABCMeta):
DEFAULT_STORY_CLS = SpeedometerStory
STORY_FILTER_CLS = SpeedometerBenchmarkStoryFilter
@classmethod
def short_base_name(cls) -> str:
return "sp"
@classmethod
def base_name(cls) -> str:
return "speedometer"