Clean up ChunkedUpdateDrawingAreaProxy
[WebKit-https.git] / Tools / Scripts / webkitpy / layout_tests / layout_package / json_results_generator.py
1 # Copyright (C) 2010 Google Inc. All rights reserved.
2 #
3 # Redistribution and use in source and binary forms, with or without
4 # modification, are permitted provided that the following conditions are
5 # met:
6 #
7 #     * Redistributions of source code must retain the above copyright
8 # notice, this list of conditions and the following disclaimer.
9 #     * Redistributions in binary form must reproduce the above
10 # copyright notice, this list of conditions and the following disclaimer
11 # in the documentation and/or other materials provided with the
12 # distribution.
13 #     * Neither the name of Google Inc. nor the names of its
14 # contributors may be used to endorse or promote products derived from
15 # this software without specific prior written permission.
16 #
17 # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
18 # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
19 # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
20 # A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
21 # OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
22 # SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
23 # LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
24 # DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
25 # THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
26 # (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
27 # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
28
29 from __future__ import with_statement
30
31 import codecs
32 import logging
33 import os
34 import subprocess
35 import sys
36 import time
37 import urllib2
38 import xml.dom.minidom
39
40 from webkitpy.layout_tests.layout_package import test_results_uploader
41
42 import webkitpy.thirdparty.simplejson as simplejson
43
44 # A JSON results generator for generic tests.
45 # FIXME: move this code out of the layout_package directory.
46
47 _log = logging.getLogger("webkitpy.layout_tests.layout_package.json_results_generator")
48
49 class TestResult(object):
50     """A simple class that represents a single test result."""
51
52     # Test modifier constants.
53     (NONE, FAILS, FLAKY, DISABLED) = range(4)
54
55     def __init__(self, name, failed=False, elapsed_time=0):
56         self.name = name
57         self.failed = failed
58         self.time = elapsed_time
59
60         test_name = name
61         try:
62             test_name = name.split('.')[1]
63         except IndexError:
64             _log.warn("Invalid test name: %s.", name)
65             pass
66
67         if test_name.startswith('FAILS_'):
68             self.modifier = self.FAILS
69         elif test_name.startswith('FLAKY_'):
70             self.modifier = self.FLAKY
71         elif test_name.startswith('DISABLED_'):
72             self.modifier = self.DISABLED
73         else:
74             self.modifier = self.NONE
75
76     def fixable(self):
77         return self.failed or self.modifier == self.DISABLED
78
79
80 class JSONResultsGeneratorBase(object):
81     """A JSON results generator for generic tests."""
82
83     MAX_NUMBER_OF_BUILD_RESULTS_TO_LOG = 750
84     # Min time (seconds) that will be added to the JSON.
85     MIN_TIME = 1
86     JSON_PREFIX = "ADD_RESULTS("
87     JSON_SUFFIX = ");"
88
89     # Note that in non-chromium tests those chars are used to indicate
90     # test modifiers (FAILS, FLAKY, etc) but not actual test results.
91     PASS_RESULT = "P"
92     SKIP_RESULT = "X"
93     FAIL_RESULT = "F"
94     FLAKY_RESULT = "L"
95     NO_DATA_RESULT = "N"
96
97     MODIFIER_TO_CHAR = {TestResult.NONE: PASS_RESULT,
98                         TestResult.DISABLED: SKIP_RESULT,
99                         TestResult.FAILS: FAIL_RESULT,
100                         TestResult.FLAKY: FLAKY_RESULT}
101
102     VERSION = 3
103     VERSION_KEY = "version"
104     RESULTS = "results"
105     TIMES = "times"
106     BUILD_NUMBERS = "buildNumbers"
107     TIME = "secondsSinceEpoch"
108     TESTS = "tests"
109
110     FIXABLE_COUNT = "fixableCount"
111     FIXABLE = "fixableCounts"
112     ALL_FIXABLE_COUNT = "allFixableCount"
113
114     RESULTS_FILENAME = "results.json"
115     INCREMENTAL_RESULTS_FILENAME = "incremental_results.json"
116
117     URL_FOR_TEST_LIST_JSON = \
118         "http://%s/testfile?builder=%s&name=%s&testlistjson=1&testtype=%s"
119
120     def __init__(self, builder_name, build_name, build_number,
121         results_file_base_path, builder_base_url,
122         test_results_map, svn_repositories=None,
123         generate_incremental_results=False,
124         test_results_server=None,
125         test_type="",
126         master_name=""):
127         """Modifies the results.json file. Grabs it off the archive directory
128         if it is not found locally.
129
130         Args
131           builder_name: the builder name (e.g. Webkit).
132           build_name: the build name (e.g. webkit-rel).
133           build_number: the build number.
134           results_file_base_path: Absolute path to the directory containing the
135               results json file.
136           builder_base_url: the URL where we have the archived test results.
137               If this is None no archived results will be retrieved.
138           test_results_map: A dictionary that maps test_name to TestResult.
139           svn_repositories: A (json_field_name, svn_path) pair for SVN
140               repositories that tests rely on.  The SVN revision will be
141               included in the JSON with the given json_field_name.
142           generate_incremental_results: If true, generate incremental json file
143               from current run results.
144           test_results_server: server that hosts test results json.
145           test_type: test type string (e.g. 'layout-tests').
146           master_name: the name of the buildbot master.
147         """
148         self._builder_name = builder_name
149         self._build_name = build_name
150         self._build_number = build_number
151         self._builder_base_url = builder_base_url
152         self._results_directory = results_file_base_path
153         self._results_file_path = os.path.join(results_file_base_path,
154             self.RESULTS_FILENAME)
155         self._incremental_results_file_path = os.path.join(
156             results_file_base_path, self.INCREMENTAL_RESULTS_FILENAME)
157
158         self._test_results_map = test_results_map
159         self._test_results = test_results_map.values()
160         self._generate_incremental_results = generate_incremental_results
161
162         self._svn_repositories = svn_repositories
163         if not self._svn_repositories:
164             self._svn_repositories = {}
165
166         self._test_results_server = test_results_server
167         self._test_type = test_type
168         self._master_name = master_name
169
170         self._json = None
171         self._archived_results = None
172
173     def generate_json_output(self):
174         """Generates the JSON output file."""
175
176         # Generate the JSON output file that has full results.
177         # FIXME: stop writing out the full results file once all bots use
178         # incremental results.
179         if not self._json:
180             self._json = self.get_json()
181         if self._json:
182             self._generate_json_file(self._json, self._results_file_path)
183
184         # Generate the JSON output file that only has incremental results.
185         if self._generate_incremental_results:
186             json = self.get_json(incremental=True)
187             if json:
188                 self._generate_json_file(
189                     json, self._incremental_results_file_path)
190
191     def get_json(self, incremental=False):
192         """Gets the results for the results.json file."""
193         results_json = {}
194         if not incremental:
195             if self._json:
196                 return self._json
197
198             if self._archived_results:
199                 results_json = self._archived_results
200
201         if not results_json:
202             results_json, error = self._get_archived_json_results(incremental)
203             if error:
204                 # If there was an error don't write a results.json
205                 # file at all as it would lose all the information on the
206                 # bot.
207                 _log.error("Archive directory is inaccessible. Not "
208                            "modifying or clobbering the results.json "
209                            "file: " + str(error))
210                 return None
211
212         builder_name = self._builder_name
213         if results_json and builder_name not in results_json:
214             _log.debug("Builder name (%s) is not in the results.json file."
215                        % builder_name)
216
217         self._convert_json_to_current_version(results_json)
218
219         if builder_name not in results_json:
220             results_json[builder_name] = (
221                 self._create_results_for_builder_json())
222
223         results_for_builder = results_json[builder_name]
224
225         self._insert_generic_metadata(results_for_builder)
226
227         self._insert_failure_summaries(results_for_builder)
228
229         # Update the all failing tests with result type and time.
230         tests = results_for_builder[self.TESTS]
231         all_failing_tests = self._get_failed_test_names()
232         all_failing_tests.update(tests.iterkeys())
233         for test in all_failing_tests:
234             self._insert_test_time_and_result(test, tests, incremental)
235
236         return results_json
237
238     def set_archived_results(self, archived_results):
239         self._archived_results = archived_results
240
241     def upload_json_files(self, json_files):
242         """Uploads the given json_files to the test_results_server (if the
243         test_results_server is given)."""
244         if not self._test_results_server:
245             return
246
247         if not self._master_name:
248             _log.error("--test-results-server was set, but --master-name was not.  Not uploading JSON files.")
249             return
250
251         _log.info("Uploading JSON files for builder: %s", self._builder_name)
252         attrs = [("builder", self._builder_name),
253                  ("testtype", self._test_type),
254                  ("master", self._master_name)]
255
256         files = [(file, os.path.join(self._results_directory, file))
257             for file in json_files]
258
259         uploader = test_results_uploader.TestResultsUploader(
260             self._test_results_server)
261         try:
262             # Set uploading timeout in case appengine server is having problem.
263             # 120 seconds are more than enough to upload test results.
264             uploader.upload(attrs, files, 120)
265         except Exception, err:
266             _log.error("Upload failed: %s" % err)
267             return
268
269         _log.info("JSON files uploaded.")
270
271     def _generate_json_file(self, json, file_path):
272         # Specify separators in order to get compact encoding.
273         json_data = simplejson.dumps(json, separators=(',', ':'))
274         json_string = self.JSON_PREFIX + json_data + self.JSON_SUFFIX
275
276         results_file = codecs.open(file_path, "w", "utf-8")
277         results_file.write(json_string)
278         results_file.close()
279
280     def _get_test_timing(self, test_name):
281         """Returns test timing data (elapsed time) in second
282         for the given test_name."""
283         if test_name in self._test_results_map:
284             # Floor for now to get time in seconds.
285             return int(self._test_results_map[test_name].time)
286         return 0
287
288     def _get_failed_test_names(self):
289         """Returns a set of failed test names."""
290         return set([r.name for r in self._test_results if r.failed])
291
292     def _get_modifier_char(self, test_name):
293         """Returns a single char (e.g. SKIP_RESULT, FAIL_RESULT,
294         PASS_RESULT, NO_DATA_RESULT, etc) that indicates the test modifier
295         for the given test_name.
296         """
297         if test_name not in self._test_results_map:
298             return self.__class__.NO_DATA_RESULT
299
300         test_result = self._test_results_map[test_name]
301         if test_result.modifier in self.MODIFIER_TO_CHAR.keys():
302             return self.MODIFIER_TO_CHAR[test_result.modifier]
303
304         return self.__class__.PASS_RESULT
305
306     def _get_result_char(self, test_name):
307         """Returns a single char (e.g. SKIP_RESULT, FAIL_RESULT,
308         PASS_RESULT, NO_DATA_RESULT, etc) that indicates the test result
309         for the given test_name.
310         """
311         if test_name not in self._test_results_map:
312             return self.__class__.NO_DATA_RESULT
313
314         test_result = self._test_results_map[test_name]
315         if test_result.modifier == TestResult.DISABLED:
316             return self.__class__.SKIP_RESULT
317
318         if test_result.failed:
319             return self.__class__.FAIL_RESULT
320
321         return self.__class__.PASS_RESULT
322
323     # FIXME: Callers should use scm.py instead.
324     # FIXME: Identify and fix the run-time errors that were observed on Windows
325     # chromium buildbot when we had updated this code to use scm.py once before.
326     def _get_svn_revision(self, in_directory):
327         """Returns the svn revision for the given directory.
328
329         Args:
330           in_directory: The directory where svn is to be run.
331         """
332         if os.path.exists(os.path.join(in_directory, '.svn')):
333             # Note: Not thread safe: http://bugs.python.org/issue2320
334             output = subprocess.Popen(["svn", "info", "--xml"],
335                                       cwd=in_directory,
336                                       shell=(sys.platform == 'win32'),
337                                       stdout=subprocess.PIPE).communicate()[0]
338             try:
339                 dom = xml.dom.minidom.parseString(output)
340                 return dom.getElementsByTagName('entry')[0].getAttribute(
341                     'revision')
342             except xml.parsers.expat.ExpatError:
343                 return ""
344         return ""
345
346     def _get_archived_json_results(self, for_incremental=False):
347         """Reads old results JSON file if it exists.
348         Returns (archived_results, error) tuple where error is None if results
349         were successfully read.
350
351         if for_incremental is True, download JSON file that only contains test
352         name list from test-results server. This is for generating incremental
353         JSON so the file generated has info for tests that failed before but
354         pass or are skipped from current run.
355         """
356         results_json = {}
357         old_results = None
358         error = None
359
360         if os.path.exists(self._results_file_path) and not for_incremental:
361             with codecs.open(self._results_file_path, "r", "utf-8") as file:
362                 old_results = file.read()
363         elif self._builder_base_url or for_incremental:
364             if for_incremental:
365                 if not self._test_results_server:
366                     # starting from fresh if no test results server specified.
367                     return {}, None
368
369                 results_file_url = (self.URL_FOR_TEST_LIST_JSON %
370                     (urllib2.quote(self._test_results_server),
371                      urllib2.quote(self._builder_name),
372                      self.RESULTS_FILENAME,
373                      urllib2.quote(self._test_type)))
374             else:
375                 # Check if we have the archived JSON file on the buildbot
376                 # server.
377                 results_file_url = (self._builder_base_url +
378                     self._build_name + "/" + self.RESULTS_FILENAME)
379                 _log.error("Local results.json file does not exist. Grabbing "
380                            "it off the archive at " + results_file_url)
381
382             try:
383                 results_file = urllib2.urlopen(results_file_url)
384                 info = results_file.info()
385                 old_results = results_file.read()
386             except urllib2.HTTPError, http_error:
387                 # A non-4xx status code means the bot is hosed for some reason
388                 # and we can't grab the results.json file off of it.
389                 if (http_error.code < 400 and http_error.code >= 500):
390                     error = http_error
391             except urllib2.URLError, url_error:
392                 error = url_error
393
394         if old_results:
395             # Strip the prefix and suffix so we can get the actual JSON object.
396             old_results = old_results[len(self.JSON_PREFIX):
397                                       len(old_results) - len(self.JSON_SUFFIX)]
398
399             try:
400                 results_json = simplejson.loads(old_results)
401             except:
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 _insert_failure_summaries(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._insert_item_into_raw_list(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._get_modifier_char(test_name)
429             entry[result_char] = entry.get(result_char, 0) + 1
430
431         # Insert the pass/skip/failure summary dictionary.
432         self._insert_item_into_raw_list(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._insert_item_into_raw_list(results_for_builder,
438             all_test_count, self.ALL_FIXABLE_COUNT)
439
440     def _insert_item_into_raw_list(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 _insert_item_run_length_encoded(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 _insert_generic_metadata(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._insert_item_into_raw_list(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             self._insert_item_into_raw_list(results_for_builder,
490                 self._get_svn_revision(path),
491                 name + 'Revision')
492
493         self._insert_item_into_raw_list(results_for_builder,
494             int(time.time()),
495             self.TIME)
496
497     def _insert_test_time_and_result(self, test_name, tests, incremental=False):
498         """ Insert a test item with its results to the given tests dictionary.
499
500         Args:
501           tests: Dictionary containing test result entries.
502         """
503
504         result = self._get_result_char(test_name)
505         time = self._get_test_timing(test_name)
506
507         if test_name not in tests:
508             tests[test_name] = self._create_results_and_times_json()
509
510         thisTest = tests[test_name]
511         if self.RESULTS in thisTest:
512             self._insert_item_run_length_encoded(result, thisTest[self.RESULTS])
513         else:
514             thisTest[self.RESULTS] = [[1, result]]
515
516         if self.TIMES in thisTest:
517             self._insert_item_run_length_encoded(time, thisTest[self.TIMES])
518         else:
519             thisTest[self.TIMES] = [[1, time]]
520
521         # Don't normalize the incremental results json because we need results
522         # for tests that pass or have no data from current run.
523         if not incremental:
524             self._normalize_results_json(thisTest, test_name, tests)
525
526     def _convert_json_to_current_version(self, results_json):
527         """If the JSON does not match the current version, converts it to the
528         current version and adds in the new version number.
529         """
530         if (self.VERSION_KEY in results_json and
531             results_json[self.VERSION_KEY] == self.VERSION):
532             return
533
534         results_json[self.VERSION_KEY] = self.VERSION
535
536     def _create_results_and_times_json(self):
537         results_and_times = {}
538         results_and_times[self.RESULTS] = []
539         results_and_times[self.TIMES] = []
540         return results_and_times
541
542     def _create_results_for_builder_json(self):
543         results_for_builder = {}
544         results_for_builder[self.TESTS] = {}
545         return results_for_builder
546
547     def _remove_items_over_max_number_of_builds(self, encoded_list):
548         """Removes items from the run-length encoded list after the final
549         item that exceeds the max number of builds to track.
550
551         Args:
552           encoded_results: run-length encoded results. An array of arrays, e.g.
553               [[3,'A'],[1,'Q']] encodes AAAQ.
554         """
555         num_builds = 0
556         index = 0
557         for result in encoded_list:
558             num_builds = num_builds + result[0]
559             index = index + 1
560             if num_builds > self.MAX_NUMBER_OF_BUILD_RESULTS_TO_LOG:
561                 return encoded_list[:index]
562         return encoded_list
563
564     def _normalize_results_json(self, test, test_name, tests):
565         """ Prune tests where all runs pass or tests that no longer exist and
566         truncate all results to maxNumberOfBuilds.
567
568         Args:
569           test: ResultsAndTimes object for this test.
570           test_name: Name of the test.
571           tests: The JSON object with all the test results for this builder.
572         """
573         test[self.RESULTS] = self._remove_items_over_max_number_of_builds(
574             test[self.RESULTS])
575         test[self.TIMES] = self._remove_items_over_max_number_of_builds(
576             test[self.TIMES])
577
578         is_all_pass = self._is_results_all_of_type(test[self.RESULTS],
579                                                    self.PASS_RESULT)
580         is_all_no_data = self._is_results_all_of_type(test[self.RESULTS],
581             self.NO_DATA_RESULT)
582         max_time = max([time[1] for time in test[self.TIMES]])
583
584         # Remove all passes/no-data from the results to reduce noise and
585         # filesize. If a test passes every run, but takes > MIN_TIME to run,
586         # don't throw away the data.
587         if is_all_no_data or (is_all_pass and max_time <= self.MIN_TIME):
588             del tests[test_name]
589
590     def _is_results_all_of_type(self, results, type):
591         """Returns whether all the results are of the given type
592         (e.g. all passes)."""
593         return len(results) == 1 and results[0][1] == type
594
595
596 # Left here not to break anything.
597 class JSONResultsGenerator(JSONResultsGeneratorBase):
598     pass