| # Copyright 2022 The Chromium Authors |
| # Use of this source code is governed by a BSD-style license that can be |
| # found in the LICENSE file. |
| |
| from enum import Enum |
| from optparse import OptionParser |
| from selenium import webdriver |
| |
| import json |
| import logging |
| import platform |
| import selenium |
| import subprocess |
| import sys |
| import time |
| import traceback |
| |
| DEFAULT_STP_DRIVER_PATH = '/Applications/Safari Technology Preview.app/Contents/MacOS/safaridriver' |
| |
| WS_DISPLAY_LIST_PATH = '/Library/Preferences/com.apple.windowserver.displays.plist' |
| |
| # Maximum number of times the benchmark will be run before giving up. |
| MAX_ATTEMPTS = 6 |
| |
| |
| class Channel(Enum): |
| UNKNOWN = 1 |
| CANARY = 2 |
| DEV = 3 |
| BETA = 4 |
| STABLE = 5 |
| |
| |
| class BrowserBench(object): |
| def __init__(self, name, version): |
| # Log more information to help identify failures. |
| logging.basicConfig(format='%(asctime)s %(message)s', level=logging.INFO) |
| self._name = name |
| self._version = version |
| self._output = None |
| self._githash = None |
| self._browser = None |
| self._driver = None |
| self._is_120 = BrowserBench._IsDisplayRefreshRate120() |
| self._channel = Channel.UNKNOWN |
| |
| @staticmethod |
| def _IsDisplayRefreshRate120(): |
| # The current refresh rate is stored in |
| # /Library/Preferences/com.apple.windowserver.displays.plist . If it has the |
| # string Hz = 120 then display is at 120. This likely isn't right if there |
| # are multiple displays, but it's good enough for the lab where we only |
| # have devices with a single display. |
| try: |
| windowserver_output = subprocess.run(["defaults", "read", |
| WS_DISPLAY_LIST_PATH], |
| capture_output=True) |
| return windowserver_output.stdout.decode('utf-8').find('Hz = 120') != -1 |
| except Exception as e: |
| logging.warning('Determining refresh rated generated exception, ' |
| 'assuming 60hz and continuing', exc_info=True) |
| return False |
| |
| |
| @staticmethod |
| def _CreateChromeDriver(optargs, channel): |
| options = webdriver.ChromeOptions() |
| args = ['enable-benchmarking' , 'no-first-run'] |
| if optargs.arguments: |
| for arg in optargs.arguments.split(','): |
| args.append(arg) |
| |
| if channel != Channel.STABLE: |
| args.append('--enable-field-trial-config') |
| logging.info('Using field trial config for non-stable channel') |
| |
| for arg in args: |
| options.add_argument(arg) |
| |
| logging.info(f'Chrome arguments {args}') |
| |
| if optargs.chrome_path: |
| options.binary_location = optargs.chrome_path |
| service = webdriver.chrome.service.Service( |
| executable_path=optargs.executable) |
| chrome = webdriver.Chrome(service=service, options=options) |
| return chrome |
| |
| @staticmethod |
| def _CreateSafariDriver(optargs): |
| params = {} |
| if optargs.executable: |
| params['exexutable_path'] = optargs.executable |
| if optargs.browser == 'stp': |
| safari_options = webdriver.safari.options.Options() |
| safari_options.use_technology_preview = 1 |
| params['desired_capabilities'] = { |
| 'browserName': safari_options.capabilities['browserName'] |
| } |
| # Stp requires executable_path. If the path is not supplied use the |
| # typical location. |
| if not optargs.executable: |
| params['executable_path'] = DEFAULT_STP_DRIVER_PATH |
| return webdriver.Safari(**params) |
| |
| def _GetBrowserVersion(self, optargs): |
| ''' |
| Returns the version of the browser. |
| ''' |
| if optargs.browser == 'safari' or optargs.browser == 'stp': |
| return BrowserBench._GetSafariVersion(optargs) |
| # Selenium provides the full version for chrome. |
| return self._driver.capabilities['browserVersion'] |
| |
| @staticmethod |
| def _GetSafariVersion(optargs): |
| # selenium does not report the build id of stp (e.g. 149), so this uses safaridriver, |
| # which is able to report the version. |
| safaridriver_executable = 'safaridriver' |
| if optargs.executable: |
| safaridriver_executable = optargs.executable |
| if optargs.browser == 'stp' and not optargs.executable: |
| safaridriver_executable = DEFAULT_STP_DRIVER_PATH |
| results = subprocess.run([safaridriver_executable, '--version'], |
| capture_output=True).stdout.decode('utf-8') |
| start_index = results.find('Safari') |
| version = results[start_index:] if start_index != -1 else results |
| return version.strip() |
| |
| @staticmethod |
| def _CreateDriver(optargs, channel): |
| if optargs.browser == 'chrome': |
| return BrowserBench._CreateChromeDriver(optargs, channel) |
| elif optargs.browser == 'safari' or optargs.browser == 'stp': |
| for i in range(0, 10): |
| try: |
| return BrowserBench._CreateSafariDriver(optargs) |
| except selenium.common.exceptions.SessionNotCreatedException as e: |
| traceback.print_exc(e) |
| logging.info('Connecting to Safari failed, will try again') |
| time.sleep(5) |
| logging.warning('Failed to connect to Safari, this likely means Safari ' |
| 'is running something else') |
| return None |
| else: |
| return None |
| |
| @staticmethod |
| def _KillBrowser(optargs): |
| if optargs.browser == 'safari' or optargs.browser == 'stp': |
| browser_process_name = ('Safari' if optargs.browser == 'safari' else |
| 'Safari Technology Preview') |
| logging.warning('Killing Safari') |
| subprocess.run(['killall', '-9', browser_process_name]) |
| # Sleep for a little bit to ensure the kill happened. |
| time.sleep(5) |
| |
| # safaridriver may be wedged, kill it too. |
| logging.warning('Killing safaridriver') |
| subprocess.run(['killall', '-9', 'safaridriver']) |
| # Sleep for a little bit to ensure the kill happened. |
| time.sleep(5) |
| |
| logging.warning('Continuing after kill') |
| return |
| # This logic is primarily for Safari, which seems to occasionally hang. Will |
| # implement for Chrome if necessary. |
| logging.warning('Not handling kill of chrome, if this is hit and test ' |
| 'fails, implement it') |
| |
| def _CreateDriverAndRun(self, optargs, channel): |
| logging.info('Creating Driver') |
| self._driver = BrowserBench._CreateDriver(optargs, channel) |
| if not self._driver: |
| raise Exception('failed to create driver') |
| self._driver.set_window_size(900, 780) |
| logging.info('About to run test') |
| return self.RunAndExtractMeasurements(self._driver, optargs) |
| |
| def _ConvertMeasurementsToSkiaFormat(self, measurements): |
| ''' |
| Processes the results from RunAndExtractMeasurements() into the format used |
| by skia, which is: |
| An array of dictionaries. Each dictionary contains a single result. |
| Expected values in the dictionary are: |
| 'key': a dictionary that contains the following entries: |
| 'sub-test': the sub test. For the final score, this is not present. |
| 'value': the type of measurement: 'score', 'max'... |
| 'measurement': the measured value. |
| The format for this is documented at |
| https://skia.googlesource.com/buildbot/+/refs/heads/main/perf/FORMAT.md |
| ''' |
| all_results = [] |
| for suite, results in measurements.items(): |
| for result in results if isinstance(results, list) else [results]: |
| converted_result = { |
| 'key': { |
| 'value': result['value'] |
| }, |
| 'measurement': result['measurement'] |
| } |
| if suite != 'score': |
| converted_result['key']['sub-test'] = suite |
| converted_result['key']['type'] = 'sub-test' |
| else: |
| converted_result['key']['type'] = 'rollup' |
| all_results.append(converted_result) |
| return all_results |
| |
| def _ProduceOutput(self, measurements, extra_key_values, optargs): |
| ''' |
| extra_key_values is a dictionary of arbitrary key/value pairs added to the |
| results. |
| ''' |
| data = { |
| 'version': 1, |
| 'git_hash': self._githash, |
| 'key': { |
| 'test': self._name, |
| 'version': self._version, |
| 'browser': self._browser, |
| 'Refresh Rate': '120' if self._is_120 else '60', |
| }, |
| 'results': self._ConvertMeasurementsToSkiaFormat(measurements), |
| 'links': { |
| # Links is used for metadata that is not interpreted by skia. Skia |
| # expects key value pairs with the value a link. As there is no a |
| # good place to link the version to, about:blank is used. |
| self._GetBrowserVersion(optargs): |
| 'about:blank', |
| } |
| } |
| data['key'].update(extra_key_values) |
| print(json.dumps(data, sort_keys=True, indent=2, separators=(',', ': '))) |
| if self._output: |
| with open(self._output, 'w') as file: |
| file.write(json.dumps(data)) |
| |
| def Run(self): |
| '''Runs the benchmark. |
| |
| Runs the benchmark end-to-end, starting from parsing the command line |
| arguments (see README.md for details), and ending with producing the output |
| to the standard output, as well as any output file specified in the command |
| line arguments. |
| ''' |
| |
| logging.info('Script starting') |
| |
| caffeinate_process = None |
| if platform.system() == 'Darwin': |
| logging.info('Starting caffeinate') |
| # Caffeinate ensures the machine is not sleeping/idle. |
| caffeinate_process = subprocess.Popen( |
| ['/usr/bin/caffeinate', '-uims', '-t', '300']) |
| |
| parser = OptionParser() |
| parser.add_option('-b', |
| '--browser', |
| dest='browser', |
| help="""The browser to use. One of chrome, safari, or stp |
| (Safari Technology Preview).""") |
| parser.add_option('-e', |
| '--executable-path', |
| dest='executable', |
| help="""Path to the executable to the driver binary. For |
| safari this is the path to safaridriver.""") |
| parser.add_option( |
| '-a', |
| '--arguments', |
| dest='arguments', |
| help='Extra arguments to pass to the browser (chrome only).') |
| parser.add_option('-g', |
| '--githash', |
| dest='githash', |
| help='A git-hash associated with this run.') |
| parser.add_option('-o', |
| '--output', |
| dest='output', |
| help='Path to the output json file.') |
| parser.add_option('--extra-keys', |
| dest='extra_key_value_pairs', |
| help='Comma separated key/value pairs added to output.') |
| parser.add_option( |
| '--chrome-path', |
| dest='chrome_path', |
| help= |
| 'Path of the chrome executable. If not specified, the default is picked' |
| ' up from chromedriver.') |
| self.AddExtraParserOptions(parser) |
| |
| (optargs, args) = parser.parse_args() |
| self._githash = optargs.githash or 'deadbeef' |
| self._output = optargs.output |
| self._browser = optargs.browser |
| |
| extra_key_values = {} |
| if optargs.extra_key_value_pairs: |
| pairs = optargs.extra_key_value_pairs.split(',') |
| assert len(pairs) % 2 == 0 |
| for i in range(0, len(pairs), 2): |
| extra_key_values[pairs[i]] = pairs[i + 1] |
| if 'channel' in extra_key_values: |
| if extra_key_values['channel'].lower() == 'canary': |
| self._channel = Channel.CANARY |
| elif extra_key_values['channel'].lower() == 'dev': |
| self._channel = Channel.DEV |
| elif extra_key_values['channel'].lower() == 'beta': |
| self._channel = Channel.BETA |
| elif extra_key_values['channel'].lower() == 'stable': |
| self._channel = Channel.STABLE |
| else: |
| logging.warning('Unknown channel') |
| |
| self.UpdateParseArgs(optargs) |
| |
| run_count = 0 |
| measurements = False |
| # Try running the benchmark a number of times. For whatever reason either |
| # Safari or safaridriver does not always complete (based on exceptions it |
| # seems the http connection to safari is prematurely closing). |
| while not measurements and run_count < MAX_ATTEMPTS: |
| run_count += 1 |
| try: |
| measurements = self._CreateDriverAndRun(optargs, self._channel) |
| break |
| except Exception as e: |
| if run_count < MAX_ATTEMPTS: |
| logging.warning('Got exception running, will try again', |
| exc_info=True) |
| else: |
| logging.critical('Got exception running, retried too many times, ' |
| 'giving up') |
| if caffeinate_process: |
| caffeinate_process.kill() |
| raise e |
| # When rerunning, first try killing the browser in hopes of state |
| # resetting. |
| BrowserBench._KillBrowser(optargs) |
| |
| logging.info('Test completed') |
| self._ProduceOutput(measurements, extra_key_values, optargs) |
| if caffeinate_process: |
| caffeinate_process.kill() |
| |
| def AddExtraParserOptions(self, parser): |
| pass |
| |
| def UpdateParseArgs(self, optargs): |
| pass |
| |
| def RunAndExtractMeasurements(self, driver, optargs): |
| '''Runs the benchmark and returns the result. |
| |
| The result is a dictionary with an entry per suite as well as an entry for |
| the overall score. The value of each entry is a list of dictionaries, with |
| the key 'value' denoting the type of value. For example: |
| { |
| 'score': [{ 'value': 'score', |
| 'measurement': 10 }], |
| 'Suite1': [{ 'value': 'score', |
| 'measurement': 11 }], |
| } |
| The has an overall score of 10, and the suite 'Suite1' has an overall |
| score of 11. Additional values types are 'min' and 'max', these are |
| optional as not all tests provide them. |
| ''' |
| return {'error': 'Benchmark has not been set up correctly.'} |