| # 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)) |