blob: 7ddba4fa0a10aac93439ab103e328eda90f837a7 [file] [log] [blame]
# Copyright (C) 2018-2023 Igalia S.L.
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:
#
# 1. Redistributions of source code must retain the above copyright notice, this
# list of conditions and the following disclaimer.
# 2. Redistributions in binary form must reproduce the above copyright notice,
# this list of conditions and the following disclaimer in the documentation
# and/or other materials provided with the distribution.
#
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
# ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
# (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
# LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
# ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
# SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
import argparse
import configparser
import hashlib
import json
import logging
import os
import subprocess
import sys
import tempfile
import urllib
from datetime import datetime
from importlib.util import spec_from_file_location, module_from_spec
from webkitpy.benchmark_runner.benchmark_runner import BenchmarkRunner
from webkitpy.benchmark_runner.browser_driver.browser_driver_factory import BrowserDriverFactory
from webkitpy.benchmark_runner.run_benchmark import default_browser, default_platform, benchmark_runner_subclasses
from webkitpy.benchmark_runner.utils import get_path_from_project_root
from webkitpy.benchmark_runner.webserver_benchmark_runner import WebServerBenchmarkRunner
_log = logging.getLogger(__name__)
BROWSERPERFDASH_DIR = os.path.dirname(os.path.abspath(__file__))
def parse_args():
parser = argparse.ArgumentParser(description='Automate the browser based performance benchmarks')
# browserperfdash specific arguments.
parser.add_argument('--config-file', dest='config_file', default=None, required=True, help='Configuration file for sending the results to the performance dashboard server(s).')
parser_group_browser_version = parser.add_mutually_exclusive_group(required=True)
parser_group_browser_version.add_argument('--browser-version', dest='browser_version', default=None, help='A string that identifies the browser version.')
parser_group_browser_version.add_argument('--query-browser-version', dest='query_browser_version', action='store_true', help='Try to automatically query the browser version.')
# arguments shared with run-benchmark.
parser.add_argument('--build-directory', dest='buildDir', help='Path to the browser executable (e.g. WebKitBuild/Release/).')
parser.add_argument('--platform', dest='platform', default=None, choices=BrowserDriverFactory.available_platforms())
parser.add_argument('--browser', dest='browser', default=None, choices=BrowserDriverFactory.available_browsers())
parser.add_argument('--driver', default=None, choices=benchmark_runner_subclasses.keys(), help='Use the specified benchmark driver. Defaults to %s.' % WebServerBenchmarkRunner.name)
parser.add_argument('--local-copy', dest='localCopy', help='Path to a local copy of the benchmark (e.g. PerformanceTests/SunSpider/).')
parser.add_argument('--count', dest='countOverride', type=int, help='Number of times to run the benchmark (e.g. 5).')
parser.add_argument('--timeout', dest='timeoutOverride', type=int, help='Number of seconds to wait for the benchmark to finish (e.g. 600).')
parser.add_argument('--save-results-directory', help='Optional: a directory where to keep a copy of the json results that are sent to the remote server.')
parser_group_timestamp = parser.add_mutually_exclusive_group(required=False)
parser_group_timestamp.add_argument('--timestamp', dest='timestamp', type=int, help='Date of when the benchmark was run that will be sent to the performance dashboard server. Format is Unix timestamp (second since epoch). Optional. The server will use as date "now" if not specified.')
parser_group_timestamp.add_argument('--timestamp-from-repo', dest='local_path_to_repo', default=None, help='Use the commit date of the checked-out git commit (HEAD) on the specified local path as the date to send to the server of when the benchmark was run. Useful for running bots in parallel (or even re-testing old checkouts) and ensuring that the timestamps of the benchmark data on the server gets the same value than the commit date of the checked-out commit on the given repository.')
parser_group_plan_selection = parser.add_mutually_exclusive_group(required=True)
parser_group_plan_selection.add_argument('--plan', dest='plan', help='Benchmark plan to run. e.g. speedometer, jetstream')
parser_group_plan_selection.add_argument('--list-plans', action='store_true', help='List the available plans')
parser_group_plan_selection.add_argument('--read-results-json', help='Instead of running a benchmark, format the output saved in JSON_FILE.')
parser_group_plan_selection.add_argument('--plans-from-config', action='store_true', help='Run the list of plans specified in the config file.')
parser_group_plan_selection.add_argument('--allplans', action='store_true', help='Run all available benchmark plans sequentially')
parser.add_argument('browser_args', nargs='*', help='Additional arguments to pass to the browser process. These are positional arguments and must follow all other arguments. If the pass through arguments begin with a dash, use `--` before the argument list begins.')
args = parser.parse_args()
if args.save_results_directory and not os.path.isdir(args.save_results_directory):
parser.error(f'The results directory "{args.save_results_directory}" does not exist')
return args
class BrowserPerfDashRunner(object):
name = 'browserperfdash_runner'
def __init__(self, args):
self._args = args
self._benchmark_runner_plan_directory = os.path.abspath(BenchmarkRunner.plan_directory())
self._browserperfdash_runner_plan_directory = os.path.abspath(os.path.join(BROWSERPERFDASH_DIR, 'plans'))
for plan_dir in [self._benchmark_runner_plan_directory, self._browserperfdash_runner_plan_directory]:
if not os.path.isdir(plan_dir):
raise Exception(f"Can't find plan directory: {plan_dir}")
self._benchmark_runner_available_plans = BenchmarkRunner.available_plans()
self._benchmark_runner_available_plans_split_subtests = self._get_benchmark_runner_split_subtests_plans(self._benchmark_runner_available_plans)
self._browserperfdash_runner_available_plans = self._load_and_list_plan_plugins()
self._available_plans = sorted(self._benchmark_runner_available_plans + self._benchmark_runner_available_plans_split_subtests + list(self._browserperfdash_runner_available_plans.keys()))
self._parse_config_file(self._args.config_file)
self._set_args_default_values_from_config_file()
self._extra_browsers_settings = self._load_extra_settings_browsers_from_config_file()
# This is the dictionary that will be sent as the HTTP POST request that browserperfdash expects
# (as defined in https://github.com/Igalia/browserperfdash/blob/master/docs/design-document.md)
# - The bot_* data its obtained from the config file
# - the browser_* data is given at startup time via command-line arguments
# - The test_* data is generated after each test run.
self._result_data = {'bot_id': None,
'bot_password': None,
'browser_id': None,
'browser_version': None,
'local_hostname': os.uname().nodename,
'test_id': None,
'test_version': None,
'test_data': None}
if args.timestamp:
self._result_data['timestamp'] = args.timestamp
date_str = datetime.fromtimestamp(self._result_data['timestamp']).isoformat()
_log.info('Will send the benchmark data as if it was generated on date: {date}'.format(date=date_str))
elif args.local_path_to_repo is not None:
if not os.path.isdir(args.local_path_to_repo):
raise ValueError('The value "{path}" to the local repository is not a valid directory'.format(path=args.local_path_to_repo))
_log.info('Checking the timestamp of the git commit checked-out (HEAD) on the repository at path "{repo_path}"'.format(repo_path=args.local_path_to_repo))
timestamp = subprocess.check_output(['git', 'log', '-1', '--pretty=%ct', 'HEAD'], cwd=args.local_path_to_repo)
timestamp = timestamp.decode('utf-8').strip()
if not timestamp.isnumeric():
raise ValueError('The git command to find the timestamp on the HEAD git commit returned an unexpected string: {timestamp}'.format(timestamp=timestamp))
timestamp = int(timestamp)
if timestamp < 0:
raise ValueError('The git command to find the timestamp on the HEAD git commit returned a negative timestamp: {timestamp}'.format(timestamp=timestamp))
date_str = datetime.fromtimestamp(timestamp).isoformat()
_log.info('Will send the benchmark data as if it was generated on date: {date}'.format(date=date_str))
self._result_data['timestamp'] = timestamp
# The precedence order for setting the value for the arguments is:
# 1 - What the user defines via command line switches (current_value is not None).
# 2 - What is defined on the config file inside the "[settings]" section.
# 3 - The default_value passed here or None.
def _get_value_from_current_or_settings_or_default(self, current_value, variable_name, default_value=None, allowed_value_list=None, check_if_is_int=False):
if current_value is not None:
return current_value
if self._config_parser.has_option('settings', variable_name):
settings_value = self._config_parser.get('settings', variable_name)
if allowed_value_list is not None:
if settings_value not in allowed_value_list:
raise ValueError('The "{variable_name}" value "{settings_value}" defined in the config file "{config_file}" is not valid. Allowed values are: "{allowed_values}"'.format(
variable_name=variable_name, settings_value=settings_value, config_file=self._args.config_file, allowed_values=' '.join(allowed_value_list)))
if check_if_is_int:
if not settings_value.isdigit():
raise ValueError('The "{variable_name}" value "{settings_value}" defined in the config file "{config_file}" is not valid. Only integer values are allowed'.format(
variable_name=variable_name, settings_value=settings_value, config_file=self._args.config_file))
settings_value = int(settings_value)
return settings_value
return default_value
def _set_args_default_values_from_config_file(self):
self._args.timeoutOverride = self._get_value_from_current_or_settings_or_default(self._args.timeoutOverride, 'timeout', check_if_is_int=True)
self._args.countOverride = self._get_value_from_current_or_settings_or_default(self._args.countOverride, 'count', check_if_is_int=True)
self._args.platform = self._get_value_from_current_or_settings_or_default(self._args.platform, 'platform', default_value=default_platform(), allowed_value_list=BrowserDriverFactory.available_platforms())
self._args.browser = self._get_value_from_current_or_settings_or_default(self._args.browser, 'browser', default_value=default_browser(), allowed_value_list=BrowserDriverFactory.available_browsers())
self._args.driver = self._get_value_from_current_or_settings_or_default(self._args.driver, 'driver', default_value=WebServerBenchmarkRunner.name, allowed_value_list=benchmark_runner_subclasses.keys())
def _set_args_from_extra_setting_browser_entry(self, entry):
if 'timeout' in entry:
self._args.timeoutOverride = int(entry['timeout'])
if 'count' in entry:
self._args.countOverride = int(entry['count'])
if 'platform' in entry:
self._args.platform = entry['platform']
if 'browser' in entry:
self._args.browser = entry['browser']
if 'driver' in entry:
self._args.driver = entry['driver']
def _load_extra_settings_browsers_from_config_file(self):
extra_browsers_settings = {}
allowed_values_in_extra_settings = ['timeout', 'count', 'platform', 'browser', 'driver', 'plan_list']
for section in self._config_parser.sections():
if section.startswith('settings:'):
extra_setting_data = {}
for variable_name in allowed_values_in_extra_settings:
if self._config_parser.has_option(section, variable_name):
extra_setting_data[variable_name] = self._config_parser.get(section, variable_name)
if not extra_setting_data:
raise ValueError(f'The extra settings named "{section}" does not contain any value from the allowed ones: "{allowed_values_in_extra_settings}"')
settings_name = ':'.join(section.split(':')[1:])
extra_browsers_settings[settings_name] = extra_setting_data
return extra_browsers_settings
def _parse_config_file(self, config_file):
if not os.path.isfile(config_file):
raise Exception('Can not open config file for uploading results at: {config_file}'.format(config_file=config_file))
self._config_parser = configparser.RawConfigParser()
self._config_parser.read(config_file)
# Validate the data
found_one_valid_server = False
for section in self._config_parser.sections():
if section == 'settings' or section.startswith('settings:'):
continue
found_one_valid_server = True
for required_key in ['bot_id', 'bot_password', 'post_url']:
if not self._config_parser.has_option(section, required_key):
raise ValueError('Missed key "{required_key}" for server "{server_name}" in config file "{config_file}"'.format(
required_key=required_key, server_name=section, config_file=config_file))
if not found_one_valid_server:
raise ValueError('At least one server should be defined on the config file "{config_file}"'.format(config_file=config_file))
# As version of the test we calculate a hash of the contents
def _get_plan_version_hash(self, plan_name):
version_hash = hashlib.md5()
plan_name = plan_name.removesuffix('-split-subtests')
if self._is_benchmark_runner_plan(plan_name):
plan_file_path = os.path.join(self._benchmark_runner_plan_directory, f'{plan_name}.plan')
else:
plan_file_path = self._browserperfdash_runner_available_plans[plan_name]['path']
with open(plan_file_path, 'r') as plan_fd:
plan_content = plan_fd.read().encode('utf-8', errors='ignore')
version_hash.update(plan_content)
# For benchmark runner plans we load the json and we add the hashes of patches and other imported plans
if self._is_benchmark_runner_plan(plan_name):
plan_dict = json.loads(plan_content)
if 'import_plan_file' in plan_dict:
imported_plan_file = os.path.join(self._benchmark_runner_plan_directory, plan_dict.pop('import_plan_file'))
with open(imported_plan_file, 'r') as imported_plan_fd:
imported_plan_content = imported_plan_fd.read().encode('utf-8', errors='ignore')
version_hash.update(imported_plan_content)
imported_plan_dict = json.loads(imported_plan_content)
imported_plan_dict.update(plan_dict)
plan_dict = imported_plan_dict
plan_patches = []
for plan_key in plan_dict:
if plan_key.endswith('_patch'):
plan_patches.append(get_path_from_project_root(plan_dict[plan_key]))
if plan_key.endswith('_patches'):
for plan_patch in plan_dict[plan_key]:
plan_patches.append(get_path_from_project_root(plan_patch))
for patch_file_path in plan_patches:
if not os.path.isfile(patch_file_path):
raise Exception('Can not find patch file "{patch_file_path}" referenced in plan "{plan_file_path}"'.format(patch_file_path=patch_file_path, plan_file_path=plan_file_path))
with open(patch_file_path, 'r') as patch_fd:
version_hash.update(patch_fd.read().encode('utf-8', errors='ignore'))
return version_hash.hexdigest()
def _get_test_data_json_string(self, temp_result_file):
temp_result_file.flush()
temp_result_file.seek(0)
temp_result_json = json.load(temp_result_file)
if 'debugOutput' in temp_result_json:
del temp_result_json['debugOutput']
return json.dumps(temp_result_json)
def _get_benchmark_runner_split_subtests_plans(self, benchmark_runner_plans):
split_subtests_plans = []
for plan in benchmark_runner_plans:
assert (not plan.endswith('-split-subtests')), 'Name collision, a plan should not end with the string "-split-subtests"'
if BenchmarkRunner.available_subtests(plan):
split_subtests_plans.append(f'{plan}-split-subtests')
return split_subtests_plans
def _load_and_list_plan_plugins(self):
plugins = {}
for filename in os.listdir(self._browserperfdash_runner_plan_directory):
if filename.endswith('.py') and not filename.startswith('_'):
plugin_path = os.path.join(self._browserperfdash_runner_plan_directory, filename)
module_name = os.path.splitext(filename)[0]
spec = spec_from_file_location(module_name, plugin_path)
module = module_from_spec(spec)
sys.modules[module_name] = module
spec.loader.exec_module(module)
if hasattr(module, "register"):
plugin = module.register()
for required_key in ['name', 'run']:
if required_key not in plugin:
raise ValueError(f'Required key "{required_key}" not found in plugin defined at "{plugin_path}"')
plugin_name = plugin['name']
if plugin_name in plugins:
raise ValueError(f'Plugin "{plugin_name}" defined in "{plugin_path}" was already defined by other plugin.')
plugins[plugin_name] = {'run': plugin['run'], 'path': plugin_path}
else:
_log.warning(f'Plugin {filename} has no register() function. Ignoring plugin')
return plugins
# urllib.request.urlopen always raises an exception when the http return code is not 200
# so this wraps the call to return the HTTPError object instead of raising the exception.
# The HTTPError object can be treated later as a http.client.HTTPResponse object.
def _send_post_request_data(self, post_url, post_data):
try:
return urllib.request.urlopen(post_url, post_data)
except urllib.error.HTTPError as e:
return e
# browserperfdash uses code 202 for handling long StreamingHttpResponses that ouput text meanwhile
# processing to avoid timeouts and then put the real reply at the end.
def _read_stream_response(self, post_request, server_name):
try:
reply_code = post_request.getcode()
if reply_code != 202:
return reply_code, post_request.read().decode('utf-8', errors='ignore')
post_reply = b''
last_status_update = datetime.now()
while True:
chunk = post_request.read(1)
if not chunk:
break
post_reply += chunk
seconds_elapased_since_status_update = (datetime.now() - last_status_update).total_seconds()
# Each minute log an status update to avoid timeouts on the worker side due to not output
if seconds_elapased_since_status_update > 60:
_log.info('Waiting for server {server_name} to process the results from {test_name} and browser {browser_name} version {browser_version} ...'.format(
server_name=server_name, test_name=self._result_data['test_id'], browser_name=self._result_data['browser_id'], browser_version=self._result_data['browser_version']))
last_status_update = datetime.now()
post_reply = post_reply.decode('utf-8', errors='ignore')
recording_reply_msg = False
reply_msg = ''
for line in post_reply.splitlines():
if line.startswith('HTTP_202_FINAL_STATUS_CODE') and '=' in line:
reply_code = int(line.split("=")[1].strip())
if recording_reply_msg:
reply_msg += line + '\n'
if line.startswith('HTTP_202_FINAL_MSG_NEXT_LINES'):
recording_reply_msg = True
reply_msg = reply_msg.strip()
return reply_code, reply_msg
except Exception as e:
reply_code = 400
reply_msg = ('Exception when trying to read the response from server {server_name}: {e}'.format(server_name=server_name, e=str(e)))
return reply_code, reply_msg
def _upload_result(self):
upload_failed = False
for server in self._config_parser.sections():
if server == 'settings' or server.startswith('settings:'):
continue
self._result_data['bot_id'] = self._config_parser.get(server, 'bot_id')
self._result_data['bot_password'] = self._config_parser.get(server, 'bot_password')
post_data = urllib.parse.urlencode(self._result_data).encode('utf-8')
post_url = self._config_parser.get(server, 'post_url')
try:
post_request = self._send_post_request_data(post_url, post_data)
reply_code, reply_msg = self._read_stream_response(post_request, server)
if reply_code == 200:
_log.info('Sucesfully uploaded results to server {server_name} for test {test_name} and browser {browser_name} version {browser_version}'.format(
server_name=server, test_name=self._result_data['test_id'], browser_name=self._result_data['browser_id'], browser_version=self._result_data['browser_version']))
else:
upload_failed = True
_log.error('The server {server_name} returned an error code: {http_error}'.format(server_name=server, http_error=reply_code))
_log.error('The error text from the server {server_name} was: "{error_text}"'.format(server_name=server, error_text=reply_msg))
except Exception as e:
upload_failed = True
_log.error('Exception while trying to upload results to server {server_name}'.format(server_name=server))
_log.error(e)
return not upload_failed
def _is_benchmark_runner_plan(self, plan):
return plan in self._benchmark_runner_available_plans
def is_browserperfdash_runner_plan(self, plan):
return plan in self._browserperfdash_runner_available_plans
def available_plans(self):
return self._available_plans
def _run_benchmark_runner_plan(self, plan, result_file_path):
assert(self._is_benchmark_runner_plan(plan)), f'plan {plan} is not a valid benchmark-runner plan'
benchmark_runner_class = benchmark_runner_subclasses[self._args.driver]
runner = benchmark_runner_class(plan,
self._args.localCopy,
self._args.countOverride,
self._args.timeoutOverride,
self._args.buildDir,
result_file_path,
self._args.platform,
self._args.browser,
None,
browser_args=self._args.browser_args)
runner.execute()
def _run_benchmark_runner_plan_split_subtests(self, plan, result_file_path):
plan = plan.removesuffix('-split-subtests')
assert(self._is_benchmark_runner_plan(plan)), f'plan {plan} is not a valid benchmark-runner plan'
subtests_to_run = BenchmarkRunner.format_subtests(BenchmarkRunner.available_subtests(plan))
total_subtests = len(subtests_to_run)
_log.info(f'Running the {total_subtests} subtests of benchmark plan {plan} one-by-one')
benchmark_name = None
split_subtest_data = {}
subtests_passed = []
subtests_failed = []
for current_subtest_index, subtest in enumerate(subtests_to_run):
_log.info(f'Running subtest {subtest} of benchmark plan {plan} [{current_subtest_index} of {total_subtests}]')
benchmark_runner_class = benchmark_runner_subclasses[self._args.driver]
with tempfile.NamedTemporaryFile() as subtest_temp_result_file:
try:
runner = benchmark_runner_class(plan,
self._args.localCopy,
self._args.countOverride,
self._args.timeoutOverride,
self._args.buildDir,
subtest_temp_result_file.name,
self._args.platform,
self._args.browser,
None,
subtests=[subtest],
browser_args=self._args.browser_args)
runner.execute()
except Exception as e:
_log.error(f'subtest {subtest} of benchmark plan {plan} failed with exception: {e}')
subtests_failed.append(subtest)
continue
subtest_temp_result_file.flush()
subtest_temp_result_file.seek(0)
temp_result_json = json.load(subtest_temp_result_file)
if 'debugOutput' in temp_result_json:
del temp_result_json['debugOutput']
benchmark_name_list = list(temp_result_json.keys())
assert(len(benchmark_name_list) == 1), "There is more than one main benchmark name in the result data, this is unexpected"
if not split_subtest_data:
# This runs only on the first iteration: build the split_subtest_data initial dict and set the value of benchmark_name
benchmark_name = benchmark_name_list[0]
split_subtest_data[benchmark_name] = {'metrics': temp_result_json[benchmark_name]['metrics'], 'tests': {}}
else:
assert(benchmark_name == benchmark_name_list[0]), f'Benchmark name should not change between subtests, expected "{benchmark_name}" but got "{benchmark_name_list[0]}"'
assert(split_subtest_data[benchmark_name]['metrics'] == temp_result_json[benchmark_name]['metrics']), 'Metrics aggregation entry should not change between subtests'
benchmark_subtests = list(temp_result_json[benchmark_name]['tests'].keys())
assert(len(benchmark_subtests) == 1), "There is more than one subtest in the data, this is unexpected"
subtest_name = benchmark_subtests[0]
assert(subtest_name not in split_subtest_data[benchmark_name]['tests']), 'Data for subtest {subtest_name} is repeated'
split_subtest_data[benchmark_name]['tests'][subtest_name] = temp_result_json[benchmark_name]['tests'][subtest_name]
subtests_passed.append(subtest)
if not subtests_passed:
raise RuntimeError(f'All subtests of benchmark plan {plan} failed: {subtests_failed}')
# Append 'split-subtests' to the benchmark name and save the joint data.
split_subtest_data[f'{benchmark_name}-split-subtests'] = split_subtest_data.pop(benchmark_name)
print(split_subtest_data)
with open(result_file_path, 'w') as f:
json.dump(split_subtest_data, f, indent=2)
f.write("\n")
_log.info(f'The following subtests of benchmark plan {plan} passed: {subtests_passed}')
if len(subtests_failed) > 0:
_log.error(f'The following subtests of benchmark plan {plan} failed: {subtests_failed}')
return len(subtests_failed)
def _run_plan(self, plan, result_file_path):
if self._is_benchmark_runner_plan(plan):
return self._run_benchmark_runner_plan(plan, result_file_path)
if self.is_browserperfdash_runner_plan(plan):
return self._browserperfdash_runner_available_plans[plan]['run'](self._browser_driver, result_file_path)
if plan.endswith('-split-subtests'):
return self._run_benchmark_runner_plan_split_subtests(plan, result_file_path)
raise NotImplementedError(f'Implementation missing to run plan {plan}')
def _save_to_results_directory(self, plan, upload_worked):
stamp = datetime.now().strftime("%Y%m%dT%H%M%S")
worked_str = 'worked' if upload_worked else 'failed'
copy_file_name = f'{self._args.browser}_{self._result_data["browser_version"]}_{plan}_{self._result_data["test_version"]}_{stamp}_upload-{worked_str}.json'
copy_file_path = os.path.join(self._args.save_results_directory, copy_file_name)
_log.info(f'Saving results to {copy_file_path}')
with open(copy_file_path, "w") as f:
json.dump(self._result_data, f, indent=2)
f.write("\n")
def _run_and_upload_plans_for_browser(self, plan_list_asked, skipped):
results = {'worked': [], 'warned': [], 'failed': []}
plan_list = []
# Sanitize the list of plans asked to run
available_plans = self.available_plans()
for plan in plan_list_asked:
if plan in available_plans:
if plan not in plan_list:
plan_list.append(plan)
else:
_log.error(f'Plan "{plan}" is not in the list of known plans: "{available_plans}"')
results['failed'].append(plan)
total_plans = len(plan_list)
# Define browser parameters
self._browser_driver = BrowserDriverFactory.create(self._args.platform, self._args.browser, self._args.browser_args)
# Get the browser version if needed
if self._args.query_browser_version:
self._args.browser_version = self._browser_driver.browser_version()
if not self._args.browser_version:
raise NotImplementedError(f'The driver for browser {self._args.browser} does not implement a way to automatically obtain the version. Please specify the version manually.')
self._result_data['browser_version'] = self._args.browser_version
self._result_data['browser_id'] = self._args.browser
# Start the benchmarks
_log.info(f'Starting benchmark run for browser {self._args.browser} version {self._args.browser_version} with {total_plans} plans: "{plan_list}"')
iteration_count = 0
for plan in plan_list:
iteration_count += 1
# Skipped is empty list [] unless --allplans parameter is passed
if plan in skipped:
_log.info(f'Skipping benchmark plan: {plan} because is listed on the Skipped file [benchmark {iteration_count} of {total_plans}]')
continue
_log.info(f'Starting benchmark plan: {plan} [benchmark {iteration_count} of {total_plans}]')
try:
with tempfile.NamedTemporaryFile() as temp_result_file:
self._result_data['local_timestamp_teststart'] = datetime.now().strftime('%s')
number_failed_subtests = self._run_plan(plan, temp_result_file.name)
subtests_failed = type(number_failed_subtests) is int and number_failed_subtests > 0
self._result_data['local_timestamp_testend'] = datetime.now().strftime('%s')
_log.info('Finished benchmark plan: {plan_name}'.format(plan_name=plan))
# Fill test info for upload
self._result_data['test_id'] = plan
self._result_data['test_version'] = self._get_plan_version_hash(plan)
# Fill obtained test results for upload
self._result_data['test_data'] = self._get_test_data_json_string(temp_result_file)
_log.info(f'Uploading results for plan: {plan} and browser {self._args.browser} version {self._result_data["browser_version"]}')
upload_worked = self._upload_result()
if upload_worked:
results['warned' if subtests_failed else 'worked'].append(plan)
else:
results['failed'].append(plan)
if self._args.save_results_directory:
self._save_to_results_directory(plan, upload_worked)
except KeyboardInterrupt:
raise
except Exception as e:
results['failed'].append(plan)
_log.exception(f'Error running benchmark plan: {plan}')
_log.error(e)
return results
def _log_results_and_calculate_retcode(self, results, settings_name):
retcode = 0
if len(results['worked']) > 0:
_log.info(f'[Settings: {settings_name}]: The following benchmark plans have been upload succesfully: {results["worked"]}')
if len(results['warned']) > 0:
_log.warning(f'[Settings: {settings_name}]: The following benchmark plans have been upload succesfully but had subtests failing: {results["warned"]}')
retcode += len(results['warned'])
if len(results['failed']) > 0:
_log.error(f'[Settings: {settings_name}]: The following benchmark plans have failed to run or to upload: {results["failed"]}')
retcode += len(results['failed'])
return retcode
def run(self):
skipped = []
plan_list = []
if self._args.plan:
if self._args.plan not in self.available_plans():
raise Exception(f"Can't find a a plan named '{self._args.plan}'")
plan_list = [self._args.plan]
elif self._args.allplans:
plan_list = self.available_plans()
skippedfile = os.path.join(self._benchmark_runner_plan_directory, 'Skipped')
if not plan_list:
raise Exception('Can\'t find any plan in the directory {plan_directory}'.format(plan_directory=self._benchmark_runner_plan_directory))
if os.path.isfile(skippedfile):
skipped = [line.strip() for line in open(skippedfile) if not line.startswith('#') and len(line) > 1]
elif self._args.plans_from_config:
if not self._config_parser.has_option('settings', 'plan_list'):
raise Exception('Can\'t find a "plan_list" key inside a "settings" section on the config file.')
plan_list_from_config_str = self._config_parser.get('settings', 'plan_list')
plan_list = plan_list_from_config_str.split()
# Run with default browser settings
results_default = self._run_and_upload_plans_for_browser(plan_list, skipped)
extra_results = {}
if self._args.plans_from_config:
# Run with extra browser settings if specified
for extra_browser_setting in self._extra_browsers_settings:
extra_browser_data = self._extra_browsers_settings[extra_browser_setting]
extra_plan_list = plan_list if 'plan_list' not in extra_browser_data else extra_browser_data['plan_list'].split()
self._set_args_from_extra_setting_browser_entry(extra_browser_data)
extra_results[extra_browser_setting] = self._run_and_upload_plans_for_browser(extra_plan_list, skipped)
# log summary and finish
retcode = 0
retcode += self._log_results_and_calculate_retcode(results_default, 'default')
for extra_browser_setting in extra_results:
retcode += self._log_results_and_calculate_retcode(extra_results[extra_browser_setting], extra_browser_setting)
return retcode
def format_logger(logger):
logger.setLevel(logging.INFO)
ch = logging.StreamHandler()
formatter = logging.Formatter('%(asctime)s - %(levelname)s - %(message)s')
ch.setFormatter(formatter)
logger.addHandler(ch)
def read_results_json(file_path):
if not os.path.isfile(file_path):
raise ValueError(f"Can't find file {file_path}")
with open(file_path, 'r') as f:
json_data = json.load(f)
is_browserperfdash_runner_json_result = "test_data" in json_data
if is_browserperfdash_runner_json_result:
print('---- TEST RUN INFO ----')
for key in json_data.keys():
if key != "test_data":
print(f"{key} = {json_data[key]}")
print('---- TEST RESULTS -----')
results_json = json.loads(json_data["test_data"]) if is_browserperfdash_runner_json_result else json_data
if 'debugOutput' in results_json:
del results_json['debugOutput']
BenchmarkRunner.show_results(results_json, True, False)
return 0
def main():
args = parse_args()
if args.read_results_json:
return read_results_json(args.read_results_json)
perfdashrunner = BrowserPerfDashRunner(args)
if args.list_plans:
print("Available plans: ")
for plan in perfdashrunner.available_plans():
print("\t%s" % plan)
return 0
return perfdashrunner.run()