1# Copyright 2014 The Chromium Authors. All rights reserved.
2# Use of this source code is governed by a BSD-style license that can be
3# found in the LICENSE file.
4
5#
6# Most of this file was ported over from Blink's
7# Tools/Scripts/webkitpy/layout_tests/layout_package/json_results_generator.py
8# Tools/Scripts/webkitpy/common/net/file_uploader.py
9#
10
11import json
12import logging
13import mimetypes
14import os
15import time
16import urllib2
17
18_log = logging.getLogger(__name__)
19
20_JSON_PREFIX = 'ADD_RESULTS('
21_JSON_SUFFIX = ');'
22
23
24def HasJSONWrapper(string):
25  return string.startswith(_JSON_PREFIX) and string.endswith(_JSON_SUFFIX)
26
27
28def StripJSONWrapper(json_content):
29  # FIXME: Kill this code once the server returns json instead of jsonp.
30  if HasJSONWrapper(json_content):
31    return json_content[len(_JSON_PREFIX):len(json_content) - len(_JSON_SUFFIX)]
32  return json_content
33
34
35def WriteJSON(json_object, file_path, callback=None):
36  # Specify separators in order to get compact encoding.
37  json_string = json.dumps(json_object, separators=(',', ':'))
38  if callback:
39    json_string = callback + '(' + json_string + ');'
40  with open(file_path, 'w') as fp:
41    fp.write(json_string)
42
43
44def ConvertTrieToFlatPaths(trie, prefix=None):
45  """Flattens the trie of paths, prepending a prefix to each."""
46  result = {}
47  for name, data in trie.iteritems():
48    if prefix:
49      name = prefix + '/' + name
50
51    if len(data) and not 'results' in data:
52      result.update(ConvertTrieToFlatPaths(data, name))
53    else:
54      result[name] = data
55
56  return result
57
58
59def AddPathToTrie(path, value, trie):
60  """Inserts a single path and value into a directory trie structure."""
61  if not '/' in path:
62    trie[path] = value
63    return
64
65  directory, _slash, rest = path.partition('/')
66  if not directory in trie:
67    trie[directory] = {}
68  AddPathToTrie(rest, value, trie[directory])
69
70
71def TestTimingsTrie(individual_test_timings):
72  """Breaks a test name into dicts by directory
73
74  foo/bar/baz.html: 1ms
75  foo/bar/baz1.html: 3ms
76
77  becomes
78  foo: {
79      bar: {
80          baz.html: 1,
81          baz1.html: 3
82      }
83  }
84  """
85  trie = {}
86  for test_result in individual_test_timings:
87    test = test_result.test_name
88
89    AddPathToTrie(test, int(1000 * test_result.test_run_time), trie)
90
91  return trie
92
93
94class TestResult(object):
95  """A simple class that represents a single test result."""
96
97  # Test modifier constants.
98  (NONE, FAILS, FLAKY, DISABLED) = range(4)
99
100  def __init__(self, test, failed=False, elapsed_time=0):
101    self.test_name = test
102    self.failed = failed
103    self.test_run_time = elapsed_time
104
105    test_name = test
106    try:
107      test_name = test.split('.')[1]
108    except IndexError:
109      _log.warn('Invalid test name: %s.', test)
110
111    if test_name.startswith('FAILS_'):
112      self.modifier = self.FAILS
113    elif test_name.startswith('FLAKY_'):
114      self.modifier = self.FLAKY
115    elif test_name.startswith('DISABLED_'):
116      self.modifier = self.DISABLED
117    else:
118      self.modifier = self.NONE
119
120  def Fixable(self):
121    return self.failed or self.modifier == self.DISABLED
122
123
124class JSONResultsGeneratorBase(object):
125  """A JSON results generator for generic tests."""
126
127  MAX_NUMBER_OF_BUILD_RESULTS_TO_LOG = 750
128  # Min time (seconds) that will be added to the JSON.
129  MIN_TIME = 1
130
131  # Note that in non-chromium tests those chars are used to indicate
132  # test modifiers (FAILS, FLAKY, etc) but not actual test results.
133  PASS_RESULT = 'P'
134  SKIP_RESULT = 'X'
135  FAIL_RESULT = 'F'
136  FLAKY_RESULT = 'L'
137  NO_DATA_RESULT = 'N'
138
139  MODIFIER_TO_CHAR = {TestResult.NONE: PASS_RESULT,
140                      TestResult.DISABLED: SKIP_RESULT,
141                      TestResult.FAILS: FAIL_RESULT,
142                      TestResult.FLAKY: FLAKY_RESULT}
143
144  VERSION = 4
145  VERSION_KEY = 'version'
146  RESULTS = 'results'
147  TIMES = 'times'
148  BUILD_NUMBERS = 'buildNumbers'
149  TIME = 'secondsSinceEpoch'
150  TESTS = 'tests'
151
152  FIXABLE_COUNT = 'fixableCount'
153  FIXABLE = 'fixableCounts'
154  ALL_FIXABLE_COUNT = 'allFixableCount'
155
156  RESULTS_FILENAME = 'results.json'
157  TIMES_MS_FILENAME = 'times_ms.json'
158  INCREMENTAL_RESULTS_FILENAME = 'incremental_results.json'
159
160  # line too long pylint: disable=C0301
161  URL_FOR_TEST_LIST_JSON = (
162      'http://%s/testfile?builder=%s&name=%s&testlistjson=1&testtype=%s&master=%s')
163  # pylint: enable=C0301
164
165  def __init__(self, builder_name, build_name, build_number,
166               results_file_base_path, builder_base_url,
167               test_results_map, svn_repositories=None,
168               test_results_server=None,
169               test_type='',
170               master_name=''):
171    """Modifies the results.json file. Grabs it off the archive directory
172    if it is not found locally.
173
174    Args
175      builder_name: the builder name (e.g. Webkit).
176      build_name: the build name (e.g. webkit-rel).
177      build_number: the build number.
178      results_file_base_path: Absolute path to the directory containing the
179          results json file.
180      builder_base_url: the URL where we have the archived test results.
181          If this is None no archived results will be retrieved.
182      test_results_map: A dictionary that maps test_name to TestResult.
183      svn_repositories: A (json_field_name, svn_path) pair for SVN
184          repositories that tests rely on.  The SVN revision will be
185          included in the JSON with the given json_field_name.
186      test_results_server: server that hosts test results json.
187      test_type: test type string (e.g. 'layout-tests').
188      master_name: the name of the buildbot master.
189    """
190    self._builder_name = builder_name
191    self._build_name = build_name
192    self._build_number = build_number
193    self._builder_base_url = builder_base_url
194    self._results_directory = results_file_base_path
195
196    self._test_results_map = test_results_map
197    self._test_results = test_results_map.values()
198
199    self._svn_repositories = svn_repositories
200    if not self._svn_repositories:
201      self._svn_repositories = {}
202
203    self._test_results_server = test_results_server
204    self._test_type = test_type
205    self._master_name = master_name
206
207    self._archived_results = None
208
209  def GenerateJSONOutput(self):
210    json_object = self.GetJSON()
211    if json_object:
212      file_path = (
213          os.path.join(
214              self._results_directory,
215              self.INCREMENTAL_RESULTS_FILENAME))
216      WriteJSON(json_object, file_path)
217
218  def GenerateTimesMSFile(self):
219    times = TestTimingsTrie(self._test_results_map.values())
220    file_path = os.path.join(self._results_directory, self.TIMES_MS_FILENAME)
221    WriteJSON(times, file_path)
222
223  def GetJSON(self):
224    """Gets the results for the results.json file."""
225    results_json = {}
226
227    if not results_json:
228      results_json, error = self._GetArchivedJSONResults()
229      if error:
230        # If there was an error don't write a results.json
231        # file at all as it would lose all the information on the
232        # bot.
233        _log.error('Archive directory is inaccessible. Not '
234                   'modifying or clobbering the results.json '
235                   'file: ' + str(error))
236        return None
237
238    builder_name = self._builder_name
239    if results_json and builder_name not in results_json:
240      _log.debug('Builder name (%s) is not in the results.json file.'
241                 % builder_name)
242
243    self._ConvertJSONToCurrentVersion(results_json)
244
245    if builder_name not in results_json:
246      results_json[builder_name] = (
247          self._CreateResultsForBuilderJSON())
248
249    results_for_builder = results_json[builder_name]
250
251    if builder_name:
252      self._InsertGenericMetaData(results_for_builder)
253
254    self._InsertFailureSummaries(results_for_builder)
255
256    # Update the all failing tests with result type and time.
257    tests = results_for_builder[self.TESTS]
258    all_failing_tests = self._GetFailedTestNames()
259    all_failing_tests.update(ConvertTrieToFlatPaths(tests))
260
261    for test in all_failing_tests:
262      self._InsertTestTimeAndResult(test, tests)
263
264    return results_json
265
266  def SetArchivedResults(self, archived_results):
267    self._archived_results = archived_results
268
269  def UploadJSONFiles(self, json_files):
270    """Uploads the given json_files to the test_results_server (if the
271    test_results_server is given)."""
272    if not self._test_results_server:
273      return
274
275    if not self._master_name:
276      _log.error(
277          '--test-results-server was set, but --master-name was not.  Not '
278          'uploading JSON files.')
279      return
280
281    _log.info('Uploading JSON files for builder: %s', self._builder_name)
282    attrs = [('builder', self._builder_name),
283             ('testtype', self._test_type),
284             ('master', self._master_name)]
285
286    files = [(json_file, os.path.join(self._results_directory, json_file))
287             for json_file in json_files]
288
289    url = 'http://%s/testfile/upload' % self._test_results_server
290    # Set uploading timeout in case appengine server is having problems.
291    # 120 seconds are more than enough to upload test results.
292    uploader = _FileUploader(url, 120)
293    try:
294      response = uploader.UploadAsMultipartFormData(files, attrs)
295      if response:
296        if response.code == 200:
297          _log.info('JSON uploaded.')
298        else:
299          _log.debug(
300              "JSON upload failed, %d: '%s'" %
301              (response.code, response.read()))
302      else:
303        _log.error('JSON upload failed; no response returned')
304    except Exception, err:
305      _log.error('Upload failed: %s' % err)
306      return
307
308  def _GetTestTiming(self, test_name):
309    """Returns test timing data (elapsed time) in second
310    for the given test_name."""
311    if test_name in self._test_results_map:
312      # Floor for now to get time in seconds.
313      return int(self._test_results_map[test_name].test_run_time)
314    return 0
315
316  def _GetFailedTestNames(self):
317    """Returns a set of failed test names."""
318    return set([r.test_name for r in self._test_results if r.failed])
319
320  def _GetModifierChar(self, test_name):
321    """Returns a single char (e.g. SKIP_RESULT, FAIL_RESULT,
322    PASS_RESULT, NO_DATA_RESULT, etc) that indicates the test modifier
323    for the given test_name.
324    """
325    if test_name not in self._test_results_map:
326      return self.__class__.NO_DATA_RESULT
327
328    test_result = self._test_results_map[test_name]
329    if test_result.modifier in self.MODIFIER_TO_CHAR.keys():
330      return self.MODIFIER_TO_CHAR[test_result.modifier]
331
332    return self.__class__.PASS_RESULT
333
334  def _get_result_char(self, test_name):
335    """Returns a single char (e.g. SKIP_RESULT, FAIL_RESULT,
336    PASS_RESULT, NO_DATA_RESULT, etc) that indicates the test result
337    for the given test_name.
338    """
339    if test_name not in self._test_results_map:
340      return self.__class__.NO_DATA_RESULT
341
342    test_result = self._test_results_map[test_name]
343    if test_result.modifier == TestResult.DISABLED:
344      return self.__class__.SKIP_RESULT
345
346    if test_result.failed:
347      return self.__class__.FAIL_RESULT
348
349    return self.__class__.PASS_RESULT
350
351  def _GetSVNRevision(self, in_directory):
352    """Returns the svn revision for the given directory.
353
354    Args:
355      in_directory: The directory where svn is to be run.
356    """
357    # This is overridden in flakiness_dashboard_results_uploader.py.
358    raise NotImplementedError()
359
360  def _GetArchivedJSONResults(self):
361    """Download JSON file that only contains test
362    name list from test-results server. This is for generating incremental
363    JSON so the file generated has info for tests that failed before but
364    pass or are skipped from current run.
365
366    Returns (archived_results, error) tuple where error is None if results
367    were successfully read.
368    """
369    results_json = {}
370    old_results = None
371    error = None
372
373    if not self._test_results_server:
374      return {}, None
375
376    results_file_url = (self.URL_FOR_TEST_LIST_JSON %
377                        (urllib2.quote(self._test_results_server),
378                         urllib2.quote(self._builder_name),
379                         self.RESULTS_FILENAME,
380                         urllib2.quote(self._test_type),
381                         urllib2.quote(self._master_name)))
382
383    try:
384      # FIXME: We should talk to the network via a Host object.
385      results_file = urllib2.urlopen(results_file_url)
386      old_results = results_file.read()
387    except urllib2.HTTPError, http_error:
388      # A non-4xx status code means the bot is hosed for some reason
389      # and we can't grab the results.json file off of it.
390      if (http_error.code < 400 and http_error.code >= 500):
391        error = http_error
392    except urllib2.URLError, url_error:
393      error = url_error
394
395    if old_results:
396      # Strip the prefix and suffix so we can get the actual JSON object.
397      old_results = StripJSONWrapper(old_results)
398
399      try:
400        results_json = json.loads(old_results)
401      except Exception:
402        _log.debug('results.json was not valid JSON. Clobbering.')
403        # The JSON file is not valid JSON. Just clobber the results.
404        results_json = {}
405    else:
406      _log.debug('Old JSON results do not exist. Starting fresh.')
407      results_json = {}
408
409    return results_json, error
410
411  def _InsertFailureSummaries(self, results_for_builder):
412    """Inserts aggregate pass/failure statistics into the JSON.
413    This method reads self._test_results and generates
414    FIXABLE, FIXABLE_COUNT and ALL_FIXABLE_COUNT entries.
415
416    Args:
417      results_for_builder: Dictionary containing the test results for a
418          single builder.
419    """
420    # Insert the number of tests that failed or skipped.
421    fixable_count = len([r for r in self._test_results if r.Fixable()])
422    self._InsertItemIntoRawList(results_for_builder,
423                                fixable_count, self.FIXABLE_COUNT)
424
425    # Create a test modifiers (FAILS, FLAKY etc) summary dictionary.
426    entry = {}
427    for test_name in self._test_results_map.iterkeys():
428      result_char = self._GetModifierChar(test_name)
429      entry[result_char] = entry.get(result_char, 0) + 1
430
431    # Insert the pass/skip/failure summary dictionary.
432    self._InsertItemIntoRawList(results_for_builder, entry,
433                                self.FIXABLE)
434
435    # Insert the number of all the tests that are supposed to pass.
436    all_test_count = len(self._test_results)
437    self._InsertItemIntoRawList(results_for_builder,
438                                all_test_count, self.ALL_FIXABLE_COUNT)
439
440  def _InsertItemIntoRawList(self, results_for_builder, item, key):
441    """Inserts the item into the list with the given key in the results for
442    this builder. Creates the list if no such list exists.
443
444    Args:
445      results_for_builder: Dictionary containing the test results for a
446          single builder.
447      item: Number or string to insert into the list.
448      key: Key in results_for_builder for the list to insert into.
449    """
450    if key in results_for_builder:
451      raw_list = results_for_builder[key]
452    else:
453      raw_list = []
454
455    raw_list.insert(0, item)
456    raw_list = raw_list[:self.MAX_NUMBER_OF_BUILD_RESULTS_TO_LOG]
457    results_for_builder[key] = raw_list
458
459  def _InsertItemRunLengthEncoded(self, item, encoded_results):
460    """Inserts the item into the run-length encoded results.
461
462    Args:
463      item: String or number to insert.
464      encoded_results: run-length encoded results. An array of arrays, e.g.
465          [[3,'A'],[1,'Q']] encodes AAAQ.
466    """
467    if len(encoded_results) and item == encoded_results[0][1]:
468      num_results = encoded_results[0][0]
469      if num_results <= self.MAX_NUMBER_OF_BUILD_RESULTS_TO_LOG:
470        encoded_results[0][0] = num_results + 1
471    else:
472      # Use a list instead of a class for the run-length encoding since
473      # we want the serialized form to be concise.
474      encoded_results.insert(0, [1, item])
475
476  def _InsertGenericMetaData(self, results_for_builder):
477    """ Inserts generic metadata (such as version number, current time etc)
478    into the JSON.
479
480    Args:
481      results_for_builder: Dictionary containing the test results for
482          a single builder.
483    """
484    self._InsertItemIntoRawList(results_for_builder,
485                                self._build_number, self.BUILD_NUMBERS)
486
487    # Include SVN revisions for the given repositories.
488    for (name, path) in self._svn_repositories:
489      # Note: for JSON file's backward-compatibility we use 'chrome' rather
490      # than 'chromium' here.
491      lowercase_name = name.lower()
492      if lowercase_name == 'chromium':
493        lowercase_name = 'chrome'
494      self._InsertItemIntoRawList(results_for_builder,
495                                  self._GetSVNRevision(path),
496                                  lowercase_name + 'Revision')
497
498    self._InsertItemIntoRawList(results_for_builder,
499                                int(time.time()),
500                                self.TIME)
501
502  def _InsertTestTimeAndResult(self, test_name, tests):
503    """ Insert a test item with its results to the given tests dictionary.
504
505    Args:
506      tests: Dictionary containing test result entries.
507    """
508
509    result = self._get_result_char(test_name)
510    test_time = self._GetTestTiming(test_name)
511
512    this_test = tests
513    for segment in test_name.split('/'):
514      if segment not in this_test:
515        this_test[segment] = {}
516      this_test = this_test[segment]
517
518    if not len(this_test):
519      self._PopulateResutlsAndTimesJSON(this_test)
520
521    if self.RESULTS in this_test:
522      self._InsertItemRunLengthEncoded(result, this_test[self.RESULTS])
523    else:
524      this_test[self.RESULTS] = [[1, result]]
525
526    if self.TIMES in this_test:
527      self._InsertItemRunLengthEncoded(test_time, this_test[self.TIMES])
528    else:
529      this_test[self.TIMES] = [[1, test_time]]
530
531  def _ConvertJSONToCurrentVersion(self, results_json):
532    """If the JSON does not match the current version, converts it to the
533    current version and adds in the new version number.
534    """
535    if self.VERSION_KEY in results_json:
536      archive_version = results_json[self.VERSION_KEY]
537      if archive_version == self.VERSION:
538        return
539    else:
540      archive_version = 3
541
542    # version 3->4
543    if archive_version == 3:
544      for results in results_json.values():
545        self._ConvertTestsToTrie(results)
546
547    results_json[self.VERSION_KEY] = self.VERSION
548
549  def _ConvertTestsToTrie(self, results):
550    if not self.TESTS in results:
551      return
552
553    test_results = results[self.TESTS]
554    test_results_trie = {}
555    for test in test_results.iterkeys():
556      single_test_result = test_results[test]
557      AddPathToTrie(test, single_test_result, test_results_trie)
558
559    results[self.TESTS] = test_results_trie
560
561  def _PopulateResutlsAndTimesJSON(self, results_and_times):
562    results_and_times[self.RESULTS] = []
563    results_and_times[self.TIMES] = []
564    return results_and_times
565
566  def _CreateResultsForBuilderJSON(self):
567    results_for_builder = {}
568    results_for_builder[self.TESTS] = {}
569    return results_for_builder
570
571  def _RemoveItemsOverMaxNumberOfBuilds(self, encoded_list):
572    """Removes items from the run-length encoded list after the final
573    item that exceeds the max number of builds to track.
574
575    Args:
576      encoded_results: run-length encoded results. An array of arrays, e.g.
577          [[3,'A'],[1,'Q']] encodes AAAQ.
578    """
579    num_builds = 0
580    index = 0
581    for result in encoded_list:
582      num_builds = num_builds + result[0]
583      index = index + 1
584      if num_builds > self.MAX_NUMBER_OF_BUILD_RESULTS_TO_LOG:
585        return encoded_list[:index]
586    return encoded_list
587
588  def _NormalizeResultsJSON(self, test, test_name, tests):
589    """ Prune tests where all runs pass or tests that no longer exist and
590    truncate all results to maxNumberOfBuilds.
591
592    Args:
593      test: ResultsAndTimes object for this test.
594      test_name: Name of the test.
595      tests: The JSON object with all the test results for this builder.
596    """
597    test[self.RESULTS] = self._RemoveItemsOverMaxNumberOfBuilds(
598        test[self.RESULTS])
599    test[self.TIMES] = self._RemoveItemsOverMaxNumberOfBuilds(
600        test[self.TIMES])
601
602    is_all_pass = self._IsResultsAllOfType(test[self.RESULTS],
603                                           self.PASS_RESULT)
604    is_all_no_data = self._IsResultsAllOfType(test[self.RESULTS],
605                                              self.NO_DATA_RESULT)
606    max_time = max([test_time[1] for test_time in test[self.TIMES]])
607
608    # Remove all passes/no-data from the results to reduce noise and
609    # filesize. If a test passes every run, but takes > MIN_TIME to run,
610    # don't throw away the data.
611    if is_all_no_data or (is_all_pass and max_time <= self.MIN_TIME):
612      del tests[test_name]
613
614  # method could be a function pylint: disable=R0201
615  def _IsResultsAllOfType(self, results, result_type):
616    """Returns whether all the results are of the given type
617    (e.g. all passes)."""
618    return len(results) == 1 and results[0][1] == result_type
619
620
621class _FileUploader(object):
622
623  def __init__(self, url, timeout_seconds):
624    self._url = url
625    self._timeout_seconds = timeout_seconds
626
627  def UploadAsMultipartFormData(self, files, attrs):
628    file_objs = []
629    for filename, path in files:
630      with file(path, 'rb') as fp:
631        file_objs.append(('file', filename, fp.read()))
632
633    # FIXME: We should use the same variable names for the formal and actual
634    # parameters.
635    content_type, data = _EncodeMultipartFormData(attrs, file_objs)
636    return self._UploadData(content_type, data)
637
638  def _UploadData(self, content_type, data):
639    start = time.time()
640    end = start + self._timeout_seconds
641    while time.time() < end:
642      try:
643        request = urllib2.Request(self._url, data,
644                                  {'Content-Type': content_type})
645        return urllib2.urlopen(request)
646      except urllib2.HTTPError as e:
647        _log.warn("Received HTTP status %s loading \"%s\".  "
648                  'Retrying in 10 seconds...' % (e.code, e.filename))
649        time.sleep(10)
650
651
652def _GetMIMEType(filename):
653  return mimetypes.guess_type(filename)[0] or 'application/octet-stream'
654
655
656# FIXME: Rather than taking tuples, this function should take more
657# structured data.
658def _EncodeMultipartFormData(fields, files):
659  """Encode form fields for multipart/form-data.
660
661  Args:
662    fields: A sequence of (name, value) elements for regular form fields.
663    files: A sequence of (name, filename, value) elements for data to be
664           uploaded as files.
665  Returns:
666    (content_type, body) ready for httplib.HTTP instance.
667
668  Source:
669    http://code.google.com/p/rietveld/source/browse/trunk/upload.py
670  """
671  BOUNDARY = '-M-A-G-I-C---B-O-U-N-D-A-R-Y-'
672  CRLF = '\r\n'
673  lines = []
674
675  for key, value in fields:
676    lines.append('--' + BOUNDARY)
677    lines.append('Content-Disposition: form-data; name="%s"' % key)
678    lines.append('')
679    if isinstance(value, unicode):
680      value = value.encode('utf-8')
681    lines.append(value)
682
683  for key, filename, value in files:
684    lines.append('--' + BOUNDARY)
685    lines.append('Content-Disposition: form-data; name="%s"; '
686                 'filename="%s"' % (key, filename))
687    lines.append('Content-Type: %s' % _GetMIMEType(filename))
688    lines.append('')
689    if isinstance(value, unicode):
690      value = value.encode('utf-8')
691    lines.append(value)
692
693  lines.append('--' + BOUNDARY + '--')
694  lines.append('')
695  body = CRLF.join(lines)
696  content_type = 'multipart/form-data; boundary=%s' % BOUNDARY
697  return content_type, body
698