chromium/tools/perf/contrib/shared_storage/page_set.py

# Copyright 2024 The Chromium Authors
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.

from collections import Counter
import json
import logging
import os
import py_utils
import six
from socket import timeout
import time

from telemetry import story
from telemetry.internal.backends.chrome_inspector import websocket
from telemetry.page import page as page_module

from contrib.shared_storage import shared_storage_shared_page_state as state
from contrib.shared_storage import utils


# Timeouts in seconds.
_ACTION_TIMEOUT = 2
_NAVIGATION_TIMEOUT = 90

# Time in seconds to sleep at end of story to let histograms finish recording.
_SLEEP_TIME = 1

# Placeholder substring for index value in the action script template.
_INDEX_PLACEHOLDER = '{{ index }}'

# Note that the true default number of iterations is defined by
# `_DEFAULT_NUM_ITERATIONS` in
# tools/perf/contrib/shared_storage/shared_storage.py.
_PLACEHOLDER_ITERATIONS = 10


# Replaces `_INDEX_PLACEHOLDER` in a param value with the index value.
def _Render(template, index):
  if not isinstance(template, str):
    raise TypeError("Expected template to be a str, but got " +
                    str(type(template)))
  if not isinstance(index, int):
    raise TypeError("Expected index to be an int, but got " + str(type(index)))
  return template.replace(_INDEX_PLACEHOLDER, str(index))


# Replaces `_INDEX_PLACEHOLDER` in an event dict with the index value.
def _RenderEvent(event_template, index):
  if not isinstance(event_template, dict):
    raise TypeError("Expected event_template to be a dict, but got " +
                    str(type(event_template)))
  if 'params' not in event_template:
    return event_template
  new_params = {
      key: _Render(event_template['params'][key], index)
      for key in event_template['params']
  }
  return {
      key: event_template[key] if key != 'params' else new_params
      for key in event_template
  }


# Replaces `_INDEX_PLACEHOLDER` in a list of event dicts with the index value.
def _RenderEvents(events_template, index):
  if not isinstance(events_template, list):
    raise TypeError("Expected events_template to be a list, but got " +
                    str(type(events_template)))
  return [_RenderEvent(event, index) for event in events_template]


# Extracts origin from a URL.
def _GetOriginFromURL(url):
  parse_result = six.moves.urllib.parse.urlparse(url)
  return '://'.join([parse_result[0], parse_result[1]])


class _MetaSharedStorageStory(type):
  """Metaclass for SharedStorageStory."""

  @property
  def ABSTRACT_STORY(cls):
    """Class field marking whether the class is abstract.

    If true, the story will NOT be instantiated and added to a Shared Storage
    story set. This field is NOT inherited by subclasses (that's why it's
    defined on the metaclass).
    """
    return cls.__dict__.get('ABSTRACT_STORY', False)


class SharedStorageStory(
    six.with_metaclass(_MetaSharedStorageStory, page_module.Page)):
  """Abstract base class for SharedStorage user stories."""

  NAME = NotImplemented
  ABSTRACT_STORY = True
  # The setup script is run once per story, before the first iteration of the
  # action script. Note that this should be an empty string when
  # `RENAVIGATE_AFTER_ACTION` is True.
  SETUP_SCRIPT = ""
  # The shared storage events that should happen in the setup, as a list of
  # dictionaries.
  # See the docstring of `InspectorBackend.WaitForSharedStorageEvents()` in
  # third_party/catapult/telemetry/telemetry/internal/backends/chrome_inspector
  # /inspector_backend.py for more information.
  EXPECTED_SETUP_EVENTS = []
  # Template for script of the action to be iterated. Instances of
  # `_INDEX_PLACEHOLDER` will be replaced with the value of iteration's index.
  ACTION_SCRIPT_TEMPLATE = NotImplemented
  # The shared storage events that should happen in the action, as a list of
  # dictionaries.
  # See the docstring of `InspectorBackend.WaitForSharedStorageEvents()` in
  # third_party/catapult/telemetry/telemetry/internal/backends/chrome_inspector
  # /inspector_backend.py for more information.
  EXPECTED_ACTION_EVENTS_TEMPLATE = NotImplemented
  # Whether the page should be reloaded after each action iteration in order to
  # refresh the database. Note that this should not be set to True when using an
  # nonempty `SETUP_SCRIPT`.
  RENAVIGATE_AFTER_ACTION = False
  # The number of "Storage.SharedStorage.Worklet.Timing.<METHOD>.Next"
  # histograms expected to be recorded for the iterator <METHOD> being tested
  # by this story, written as a string literal to be evaluated by `eval()`, so
  # so that the value can depend on `self.SIZE`.
  EXPECTED_ITERATOR_HISTOGRAM_COUNT = "0"

  def __init__(self,
               story_set,
               url,
               size,
               shared_page_state_class,
               enable_memory_metric,
               iterations=_PLACEHOLDER_ITERATIONS,
               verbosity=0):
    super(SharedStorageStory,
          self).__init__(shared_page_state_class=shared_page_state_class,
                         page_set=story_set,
                         name=self.NAME,
                         url=url)
    self._size = size
    self._enable_memory_metric = enable_memory_metric
    self._iterations = iterations
    self._verbosity = verbosity

    if len(self.SETUP_SCRIPT) > 0 and self.RENAVIGATE_AFTER_ACTION:
      # The expected histogram count would have been incorrect because it
      # assumes this condition won't happen.
      msg_list = [
          '`RENAVIGATE_AFTER_ACTION` is True with nonempty',
          ' `SETUP_SCRIPT`: %s' % self.SETUP_SCRIPT,
          '\n`SETUP_SCRIPT` will only be run during the initial ',
          'navigation.\n It will not run during subsequent ',
          're-navigations.\nConsider incorporting content of ',
          '`SETUP_SCRIPT` into `ACTION_SCRIPT_TEMPLATE`.'
      ]
      raise RuntimeError(''.join(msg_list))

  @property
  def SIZE(self):
    return self._size

  # TODO(crbug.com/41489492): Wait for relevant Shared Storage timing histograms
  # to be recorded in each step, rather than simply the event notifications.
  #
  # Note that this will require retrieving histograms from renderer processes;
  # the current DevTools Protocol 'Browser.getHistograms' method only retrieves
  # browser-process histograms.
  #
  # Alternatively, implement the ability to log what the expected histogram
  # total counts should be at the end of the test run.
  def RunPageInteractions(self, action_runner):
    action_runner.tab.WaitForDocumentReadyStateToBeComplete(_NAVIGATION_TIMEOUT)
    self._LogMetadataIfVerbose(action_runner, False)

    action_runner.tab.EnableSharedStorageNotifications()
    self._RunSharedStorageSetUp(action_runner)

    for index in range(self._iterations):
      self._PrintProgressBarIfNonVerbose(index)
      try:
        self._RunSharedStorageAction(action_runner, index)
      except timeout as t:
        logging.warning("%s's action timed out after %d seconds: %s" %
                        (self.NAME, _ACTION_TIMEOUT, repr(t)))
      except websocket.WebSocketTimeoutException as w:
        logging.warning("%s's action timed out after %d seconds: %s" %
                        (self.NAME, _ACTION_TIMEOUT, repr(w)))

      # Reload the page if necessary. Otherwise, skip.
      if self.RENAVIGATE_AFTER_ACTION and index < self._iterations - 1:
        url = self.file_path_url_with_scheme if self.is_file else self.url
        action_runner.Navigate(url,
                               self.script_to_evaluate_on_commit,
                               timeout_in_seconds=_NAVIGATION_TIMEOUT)
        action_runner.tab.WaitForDocumentReadyStateToBeComplete(
            _NAVIGATION_TIMEOUT)
        action_runner.tab.ClearSharedStorageNotifications()

    # Sleep for a little to allow histograms to finish recording.
    time.sleep(_SLEEP_TIME)

    if self._enable_memory_metric:
      action_runner.MeasureMemory(deterministic_mode=True)

    self._LogMetadataIfVerbose(action_runner, True)
    self._WriteExpectedHistogramCountsIfNeeded()

    # Navigate away to an untracked page to trigger recording of metrics
    # requiring document destruction.
    action_runner.Navigate('about:blank',
                           self.script_to_evaluate_on_commit,
                           timeout_in_seconds=_NAVIGATION_TIMEOUT)
    action_runner.tab.DisableSharedStorageNotifications()

  def _RunSharedStorageSetUp(self, action_runner):
    if self.SETUP_SCRIPT == "":
      logging.info("no setup")
      return
    logging.info("".join(["running set up: ", self.SETUP_SCRIPT]))
    action_runner.tab.EvaluateJavaScript(self.SETUP_SCRIPT, promise=True)
    action_runner.tab.WaitForSharedStorageEvents(self.EXPECTED_SETUP_EVENTS,
                                                 mode='strict',
                                                 timeout=_ACTION_TIMEOUT)
    action_runner.tab.ClearSharedStorageNotifications()

  def _RunSharedStorageAction(self, action_runner, index):
    logging.info("".join(
        ["running iteration ",
         str(index), ": ", self.ACTION_SCRIPT_TEMPLATE]))
    if self.ACTION_SCRIPT_TEMPLATE.find(_INDEX_PLACEHOLDER) != -1:
      action_runner.tab.EvaluateJavaScript(self.ACTION_SCRIPT_TEMPLATE,
                                           promise=True,
                                           index=index)
    else:
      action_runner.tab.EvaluateJavaScript(self.ACTION_SCRIPT_TEMPLATE,
                                           promise=True)
    expected_events = _RenderEvents(self.EXPECTED_ACTION_EVENTS_TEMPLATE,
                                    index=index)
    action_runner.tab.WaitForSharedStorageEvents(expected_events,
                                                 mode='strict',
                                                 timeout=_ACTION_TIMEOUT)
    action_runner.tab.ClearSharedStorageNotifications()

  def _LogMetadataIfVerbose(self, action_runner, is_post):
    if self._verbosity < 1:
      return
    prefix = 'Post' if is_post else 'Pre'
    template = "-test shared storage metadata:\norigin: %s\n%s\n"
    try:
      origin = _GetOriginFromURL(action_runner.tab.url)
      metadata = action_runner.tab.GetSharedStorageMetadata(origin)
      json_data = json.dumps(metadata, indent=2)
      log_msg = prefix + (template % (origin, json_data))
      logging.info(log_msg)
    except timeout as t:
      logging.warning("%s timed out getting %s-test metadata: %s" %
                      (self.NAME, prefix, repr(t)))
    except websocket.WebSocketTimeoutException as w:
      logging.warning("%s timed out getting %s-test metadata: %s" %
                      (self.NAME, prefix, repr(w)))

  def _PrintProgressBarIfNonVerbose(self, index):
    if self._verbosity >= 1:
      # We don't need a progress bar because we are already logging information
      # to track each action iteration.
      return

    progress = ''.join(
        ['[', '#' * (index + 1), ' ' * (self._iterations - 1 - index), '] '])
    fraction = ''.join([str(index + 1), ' / ', str(self._iterations)])

    # We use `print()` instead of logging so that the progress bar will print
    # with no prefix and in spite of having `self._verbosity < 1`.
    print(f'{progress}{fraction} iterations', end='\r')
    if index == self._iterations - 1:
      print()

  def _WriteExpectedHistogramCountsIfNeeded(self):
    story_counts_so_far = utils.GetExpectedHistogramsDictionary()
    if self.NAME in story_counts_so_far:
      return
    current_counts = self._CalculateExpectedHistogramCountsPerRepeat()
    story_counts_so_far[self.NAME] = current_counts
    logging.info("Story '%s' expected histogram counts: %s" %
                 (self.NAME, utils.JsonDump(current_counts)))
    with open(utils.GetExpectedHistogramsFile(), 'w') as f:
      f.write(utils.JsonDump(story_counts_so_far))
    logging.info('Wrote expected histograms for "%s" to file://%s.' %
                 (self.NAME, utils.GetExpectedHistogramsFile()))

  def _GetHistogramCountsFromEvents(self, events):
    event_counts = Counter(event['type'] for event in events)
    histogram_counts = Counter()
    for event_type, count in event_counts.items():
      for name in utils.GetHistogramsFromEventType(event_type):
        if name in utils.GetSharedStorageIteratorHistograms():
          histogram_counts[name] = eval(self.EXPECTED_ITERATOR_HISTOGRAM_COUNT)
        else:
          histogram_counts[name] = count
    return histogram_counts

  def _MultiplyCounterValuesByIterations(self, counts):
    return Counter(
        dict(map(lambda h: (h[0], h[1] * self._iterations), counts.items())))

  def _CalculateExpectedHistogramCountsPerRepeat(self):
    # The number of histograms we expect to be recorded based on navigation to
    # self.URL.
    counts = Counter({
        "Storage.SharedStorage.Document.Timing.AddModule": 1,
        "Storage.SharedStorage.Document.Timing.Clear": 1,
        "Storage.SharedStorage.Document.Timing.Set": self._size,
    })

    if self.RENAVIGATE_AFTER_ACTION:
      counts = self._MultiplyCounterValuesByIterations(counts)
    elif (len(self.SETUP_SCRIPT) > 0 and len(self.EXPECTED_SETUP_EVENTS) > 0):
      setup_counts = self._GetHistogramCountsFromEvents(
          self.EXPECTED_SETUP_EVENTS)
      counts += Counter(setup_counts)
    if len(self.EXPECTED_ACTION_EVENTS_TEMPLATE) > 0:
      action_counts = self._GetHistogramCountsFromEvents(
          self.EXPECTED_ACTION_EVENTS_TEMPLATE)
      counts += self._MultiplyCounterValuesByIterations(action_counts)
    return counts


def _IterAllSharedStorageStoryClasses():
  """Generator for SharedStorage stories.

  Yields:
    All appropriate SharedStorageStory subclasses defining stories.
  """
  start_dir = os.path.dirname(os.path.abspath(__file__))
  # Sort the classes by their names so that their order is stable and
  # deterministic.
  for unused_cls_name, cls in sorted(
      py_utils.discover.DiscoverClasses(
          start_dir=start_dir,
          top_level_dir=os.path.dirname(start_dir),
          base_class=SharedStorageStory).items()):
    yield cls


class SharedStorageStorySet(story.StorySet):

  def __init__(self,
               url,
               size,
               enable_memory_metric,
               user_agent='desktop',
               iterations=_PLACEHOLDER_ITERATIONS,
               verbosity=0,
               xvfb_process=None):
    super(SharedStorageStorySet, self).__init__()
    self.xvfb_process = xvfb_process
    if user_agent == 'mobile':
      shared_page_state_class = state.SharedStorageSharedMobilePageState
    elif user_agent == 'desktop':
      shared_page_state_class = state.SharedStorageSharedDesktopPageState
    else:
      raise ValueError('user_agent %s is unrecognized' % user_agent)

    def IncludeStory(story_class):
      return not story_class.ABSTRACT_STORY

    for story_class in _IterAllSharedStorageStoryClasses():
      if IncludeStory(story_class):
        if user_agent == 'mobile':
          # Extra browser args are disabled in the mobile user agent
          story_class.EXTRA_BROWSER_ARGUMENTS = []
          logging.warning(''.join([
              'Extra browser arguments are not ',
              'available; unable to enable shared ',
              'storage from the command line.'
          ]))
        self.AddStory(
            story_class(self,
                        url=url,
                        size=size,
                        shared_page_state_class=shared_page_state_class,
                        enable_memory_metric=enable_memory_metric,
                        iterations=iterations,
                        verbosity=verbosity))