blob: 8cd8114d211a9f617415a3eedb5e371a685a0ae5 [file] [log] [blame]
# 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.'}