summaryrefslogtreecommitdiff
path: root/chromium/tools/browserbench-webdriver/browserbench.py
blob: b8b0bf8cf70ddd9a4f75d04a2c8dacbc372202e2 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
# 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 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'

# Maximum number of times the benchmark will be run before giving up.
MAX_ATTEMPTS = 6


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

  @staticmethod
  def _CreateChromeDriver(optargs):
    options = webdriver.ChromeOptions()
    options.add_argument('enable-benchmarking')
    if optargs.arguments:
      for arg in optargs.arguments.split(','):
        options.add_argument(arg)
    else:
      # If no arguments were given, enable field trial config and no first run.
      # These ensure a consistent set of flags.
      options.add_argument('--no-first-run')
      options.add_argument('--enable-field-trial-config')

    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):
    if optargs.browser == 'chrome':
      return BrowserBench._CreateChromeDriver(optargs)
    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):
    logging.info('Creating Driver')
    self._driver = BrowserBench._CreateDriver(optargs)
    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,
        },
        '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]

    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)
        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.'}