1 # Copyright (C) 2010 Google Inc. All rights reserved.
3 # Redistribution and use in source and binary forms, with or without
4 # modification, are permitted provided that the following conditions are
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
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.
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.
35 import xml.dom.minidom
37 from webkitpy.common.net.file_uploader import FileUploader
42 # python 2.5 compatibility
43 import webkitpy.thirdparty.simplejson as json
45 # A JSON results generator for generic tests.
46 # FIXME: move this code out of the layout_package directory.
48 _log = logging.getLogger(__name__)
50 _JSON_PREFIX = "ADD_RESULTS("
54 def has_json_wrapper(string):
55 return string.startswith(_JSON_PREFIX) and string.endswith(_JSON_SUFFIX)
58 def strip_json_wrapper(json_content):
59 if has_json_wrapper(json_content):
60 return json_content[len(_JSON_PREFIX):len(json_content) - len(_JSON_SUFFIX)]
64 def load_json(filesystem, file_path):
65 content = filesystem.read_text_file(file_path)
66 content = strip_json_wrapper(content)
67 return json.loads(content)
70 def write_json(filesystem, json_object, file_path):
71 # Specify separators in order to get compact encoding.
72 json_data = json.dumps(json_object, separators=(',', ':'))
73 json_string = _JSON_PREFIX + json_data + _JSON_SUFFIX
74 filesystem.write_text_file(file_path, json_string)
77 def convert_trie_to_flat_paths(trie, prefix=None):
78 """Converts the directory structure in the given trie to flat paths, prepending a prefix to each."""
80 for name, data in trie.iteritems():
82 name = prefix + "/" + name
84 if len(data) and not "results" in data:
85 result.update(convert_trie_to_flat_paths(data, name))
92 def add_path_to_trie(path, value, trie):
93 """Inserts a single flat directory path and associated value into a directory trie structure."""
98 directory, slash, rest = path.partition("/")
99 if not directory in trie:
101 add_path_to_trie(rest, value, trie[directory])
103 def test_timings_trie(port, individual_test_timings):
104 """Breaks a test name into chunks by directory and puts the test time as a value in the lowest part, e.g.
105 foo/bar/baz.html: 1ms
106 foo/bar/baz1.html: 3ms
117 for test_result in individual_test_timings:
118 test = test_result.test_name
120 add_path_to_trie(test, int(1000 * test_result.test_run_time), trie)
124 # FIXME: We already have a TestResult class in test_results.py
125 class TestResult(object):
126 """A simple class that represents a single test result."""
128 # Test modifier constants.
129 (NONE, FAILS, FLAKY, DISABLED) = range(4)
131 def __init__(self, test, failed=False, elapsed_time=0):
132 self.test_name = test
134 self.test_run_time = elapsed_time
138 test_name = test.split('.')[1]
140 _log.warn("Invalid test name: %s.", test)
143 if test_name.startswith('FAILS_'):
144 self.modifier = self.FAILS
145 elif test_name.startswith('FLAKY_'):
146 self.modifier = self.FLAKY
147 elif test_name.startswith('DISABLED_'):
148 self.modifier = self.DISABLED
150 self.modifier = self.NONE
153 return self.failed or self.modifier == self.DISABLED
156 class JSONResultsGeneratorBase(object):
157 """A JSON results generator for generic tests."""
159 MAX_NUMBER_OF_BUILD_RESULTS_TO_LOG = 750
160 # Min time (seconds) that will be added to the JSON.
163 # Note that in non-chromium tests those chars are used to indicate
164 # test modifiers (FAILS, FLAKY, etc) but not actual test results.
171 MODIFIER_TO_CHAR = {TestResult.NONE: PASS_RESULT,
172 TestResult.DISABLED: SKIP_RESULT,
173 TestResult.FAILS: FAIL_RESULT,
174 TestResult.FLAKY: FLAKY_RESULT}
177 VERSION_KEY = "version"
180 BUILD_NUMBERS = "buildNumbers"
181 TIME = "secondsSinceEpoch"
184 FIXABLE_COUNT = "fixableCount"
185 FIXABLE = "fixableCounts"
186 ALL_FIXABLE_COUNT = "allFixableCount"
188 RESULTS_FILENAME = "results.json"
189 TIMES_MS_FILENAME = "times_ms.json"
190 INCREMENTAL_RESULTS_FILENAME = "incremental_results.json"
192 URL_FOR_TEST_LIST_JSON = \
193 "http://%s/testfile?builder=%s&name=%s&testlistjson=1&testtype=%s"
195 # FIXME: Remove generate_incremental_results once the reference to it in
196 # http://src.chromium.org/viewvc/chrome/trunk/tools/build/scripts/slave/gtest_slave_utils.py
198 def __init__(self, port, builder_name, build_name, build_number,
199 results_file_base_path, builder_base_url,
200 test_results_map, svn_repositories=None,
201 test_results_server=None,
204 generate_incremental_results=None):
205 """Modifies the results.json file. Grabs it off the archive directory
206 if it is not found locally.
209 port: port-specific wrapper
210 builder_name: the builder name (e.g. Webkit).
211 build_name: the build name (e.g. webkit-rel).
212 build_number: the build number.
213 results_file_base_path: Absolute path to the directory containing the
215 builder_base_url: the URL where we have the archived test results.
216 If this is None no archived results will be retrieved.
217 test_results_map: A dictionary that maps test_name to TestResult.
218 svn_repositories: A (json_field_name, svn_path) pair for SVN
219 repositories that tests rely on. The SVN revision will be
220 included in the JSON with the given json_field_name.
221 test_results_server: server that hosts test results json.
222 test_type: test type string (e.g. 'layout-tests').
223 master_name: the name of the buildbot master.
226 self._fs = port._filesystem
227 self._builder_name = builder_name
228 self._build_name = build_name
229 self._build_number = build_number
230 self._builder_base_url = builder_base_url
231 self._results_directory = results_file_base_path
233 self._test_results_map = test_results_map
234 self._test_results = test_results_map.values()
236 self._svn_repositories = svn_repositories
237 if not self._svn_repositories:
238 self._svn_repositories = {}
240 self._test_results_server = test_results_server
241 self._test_type = test_type
242 self._master_name = master_name
244 self._archived_results = None
246 def generate_json_output(self):
247 json_object = self.get_json()
249 file_path = self._fs.join(self._results_directory, self.INCREMENTAL_RESULTS_FILENAME)
250 write_json(self._fs, json_object, file_path)
252 def generate_times_ms_file(self):
253 # FIXME: rename to generate_times_ms_file. This needs to be coordinated with
254 # changing the calls to this on the chromium build slaves.
255 times = test_timings_trie(self._port, self._test_results_map.values())
256 file_path = self._fs.join(self._results_directory, self.TIMES_MS_FILENAME)
257 write_json(self._fs, times, file_path)
260 """Gets the results for the results.json file."""
264 results_json, error = self._get_archived_json_results()
266 # If there was an error don't write a results.json
267 # file at all as it would lose all the information on the
269 _log.error("Archive directory is inaccessible. Not "
270 "modifying or clobbering the results.json "
271 "file: " + str(error))
274 builder_name = self._builder_name
275 if results_json and builder_name not in results_json:
276 _log.debug("Builder name (%s) is not in the results.json file."
279 self._convert_json_to_current_version(results_json)
281 if builder_name not in results_json:
282 results_json[builder_name] = (
283 self._create_results_for_builder_json())
285 results_for_builder = results_json[builder_name]
287 self._insert_generic_metadata(results_for_builder)
289 self._insert_failure_summaries(results_for_builder)
291 # Update the all failing tests with result type and time.
292 tests = results_for_builder[self.TESTS]
293 all_failing_tests = self._get_failed_test_names()
294 all_failing_tests.update(convert_trie_to_flat_paths(tests))
296 for test in all_failing_tests:
297 self._insert_test_time_and_result(test, tests)
301 def set_archived_results(self, archived_results):
302 self._archived_results = archived_results
304 def upload_json_files(self, json_files):
305 """Uploads the given json_files to the test_results_server (if the
306 test_results_server is given)."""
307 if not self._test_results_server:
310 if not self._master_name:
311 _log.error("--test-results-server was set, but --master-name was not. Not uploading JSON files.")
314 _log.info("Uploading JSON files for builder: %s", self._builder_name)
315 attrs = [("builder", self._builder_name),
316 ("testtype", self._test_type),
317 ("master", self._master_name)]
319 files = [(file, self._fs.join(self._results_directory, file))
320 for file in json_files]
322 url = "http://%s/testfile/upload" % self._test_results_server
323 uploader = FileUploader(url)
325 # Set uploading timeout in case appengine server is having problem.
326 # 120 seconds are more than enough to upload test results.
327 uploader.upload(attrs, files, 120)
328 except Exception, err:
329 _log.error("Upload failed: %s" % err)
332 _log.info("JSON files uploaded.")
334 def _get_test_timing(self, test_name):
335 """Returns test timing data (elapsed time) in second
336 for the given test_name."""
337 if test_name in self._test_results_map:
338 # Floor for now to get time in seconds.
339 return int(self._test_results_map[test_name].test_run_time)
342 def _get_failed_test_names(self):
343 """Returns a set of failed test names."""
344 return set([r.test_name for r in self._test_results if r.failed])
346 def _get_modifier_char(self, test_name):
347 """Returns a single char (e.g. SKIP_RESULT, FAIL_RESULT,
348 PASS_RESULT, NO_DATA_RESULT, etc) that indicates the test modifier
349 for the given test_name.
351 if test_name not in self._test_results_map:
352 return self.__class__.NO_DATA_RESULT
354 test_result = self._test_results_map[test_name]
355 if test_result.modifier in self.MODIFIER_TO_CHAR.keys():
356 return self.MODIFIER_TO_CHAR[test_result.modifier]
358 return self.__class__.PASS_RESULT
360 def _get_result_char(self, test_name):
361 """Returns a single char (e.g. SKIP_RESULT, FAIL_RESULT,
362 PASS_RESULT, NO_DATA_RESULT, etc) that indicates the test result
363 for the given test_name.
365 if test_name not in self._test_results_map:
366 return self.__class__.NO_DATA_RESULT
368 test_result = self._test_results_map[test_name]
369 if test_result.modifier == TestResult.DISABLED:
370 return self.__class__.SKIP_RESULT
372 if test_result.failed:
373 return self.__class__.FAIL_RESULT
375 return self.__class__.PASS_RESULT
377 # FIXME: Callers should use scm.py instead.
378 # FIXME: Identify and fix the run-time errors that were observed on Windows
379 # chromium buildbot when we had updated this code to use scm.py once before.
380 def _get_svn_revision(self, in_directory):
381 """Returns the svn revision for the given directory.
384 in_directory: The directory where svn is to be run.
386 if self._fs.exists(self._fs.join(in_directory, '.svn')):
387 # Note: Not thread safe: http://bugs.python.org/issue2320
388 output = subprocess.Popen(["svn", "info", "--xml"],
390 shell=(sys.platform == 'win32'),
391 stdout=subprocess.PIPE).communicate()[0]
393 dom = xml.dom.minidom.parseString(output)
394 return dom.getElementsByTagName('entry')[0].getAttribute(
396 except xml.parsers.expat.ExpatError:
400 def _get_archived_json_results(self):
401 """Download JSON file that only contains test
402 name list from test-results server. This is for generating incremental
403 JSON so the file generated has info for tests that failed before but
404 pass or are skipped from current run.
406 Returns (archived_results, error) tuple where error is None if results
407 were successfully read.
413 if not self._test_results_server:
416 results_file_url = (self.URL_FOR_TEST_LIST_JSON %
417 (urllib2.quote(self._test_results_server),
418 urllib2.quote(self._builder_name),
419 self.RESULTS_FILENAME,
420 urllib2.quote(self._test_type)))
423 # FIXME: We should talk to the network via a Host object.
424 results_file = urllib2.urlopen(results_file_url)
425 info = results_file.info()
426 old_results = results_file.read()
427 except urllib2.HTTPError, http_error:
428 # A non-4xx status code means the bot is hosed for some reason
429 # and we can't grab the results.json file off of it.
430 if (http_error.code < 400 and http_error.code >= 500):
432 except urllib2.URLError, url_error:
436 # Strip the prefix and suffix so we can get the actual JSON object.
437 old_results = strip_json_wrapper(old_results)
440 results_json = json.loads(old_results)
442 _log.debug("results.json was not valid JSON. Clobbering.")
443 # The JSON file is not valid JSON. Just clobber the results.
446 _log.debug('Old JSON results do not exist. Starting fresh.')
449 return results_json, error
451 def _insert_failure_summaries(self, results_for_builder):
452 """Inserts aggregate pass/failure statistics into the JSON.
453 This method reads self._test_results and generates
454 FIXABLE, FIXABLE_COUNT and ALL_FIXABLE_COUNT entries.
457 results_for_builder: Dictionary containing the test results for a
460 # Insert the number of tests that failed or skipped.
461 fixable_count = len([r for r in self._test_results if r.fixable()])
462 self._insert_item_into_raw_list(results_for_builder,
463 fixable_count, self.FIXABLE_COUNT)
465 # Create a test modifiers (FAILS, FLAKY etc) summary dictionary.
467 for test_name in self._test_results_map.iterkeys():
468 result_char = self._get_modifier_char(test_name)
469 entry[result_char] = entry.get(result_char, 0) + 1
471 # Insert the pass/skip/failure summary dictionary.
472 self._insert_item_into_raw_list(results_for_builder, entry,
475 # Insert the number of all the tests that are supposed to pass.
476 all_test_count = len(self._test_results)
477 self._insert_item_into_raw_list(results_for_builder,
478 all_test_count, self.ALL_FIXABLE_COUNT)
480 def _insert_item_into_raw_list(self, results_for_builder, item, key):
481 """Inserts the item into the list with the given key in the results for
482 this builder. Creates the list if no such list exists.
485 results_for_builder: Dictionary containing the test results for a
487 item: Number or string to insert into the list.
488 key: Key in results_for_builder for the list to insert into.
490 if key in results_for_builder:
491 raw_list = results_for_builder[key]
495 raw_list.insert(0, item)
496 raw_list = raw_list[:self.MAX_NUMBER_OF_BUILD_RESULTS_TO_LOG]
497 results_for_builder[key] = raw_list
499 def _insert_item_run_length_encoded(self, item, encoded_results):
500 """Inserts the item into the run-length encoded results.
503 item: String or number to insert.
504 encoded_results: run-length encoded results. An array of arrays, e.g.
505 [[3,'A'],[1,'Q']] encodes AAAQ.
507 if len(encoded_results) and item == encoded_results[0][1]:
508 num_results = encoded_results[0][0]
509 if num_results <= self.MAX_NUMBER_OF_BUILD_RESULTS_TO_LOG:
510 encoded_results[0][0] = num_results + 1
512 # Use a list instead of a class for the run-length encoding since
513 # we want the serialized form to be concise.
514 encoded_results.insert(0, [1, item])
516 def _insert_generic_metadata(self, results_for_builder):
517 """ Inserts generic metadata (such as version number, current time etc)
521 results_for_builder: Dictionary containing the test results for
524 self._insert_item_into_raw_list(results_for_builder,
525 self._build_number, self.BUILD_NUMBERS)
527 # Include SVN revisions for the given repositories.
528 for (name, path) in self._svn_repositories:
529 self._insert_item_into_raw_list(results_for_builder,
530 self._get_svn_revision(path),
533 self._insert_item_into_raw_list(results_for_builder,
537 def _insert_test_time_and_result(self, test_name, tests):
538 """ Insert a test item with its results to the given tests dictionary.
541 tests: Dictionary containing test result entries.
544 result = self._get_result_char(test_name)
545 time = self._get_test_timing(test_name)
548 for segment in test_name.split("/"):
549 if segment not in this_test:
550 this_test[segment] = {}
551 this_test = this_test[segment]
553 if not len(this_test):
554 self._populate_results_and_times_json(this_test)
556 if self.RESULTS in this_test:
557 self._insert_item_run_length_encoded(result, this_test[self.RESULTS])
559 this_test[self.RESULTS] = [[1, result]]
561 if self.TIMES in this_test:
562 self._insert_item_run_length_encoded(time, this_test[self.TIMES])
564 this_test[self.TIMES] = [[1, time]]
566 def _convert_json_to_current_version(self, results_json):
567 """If the JSON does not match the current version, converts it to the
568 current version and adds in the new version number.
570 if self.VERSION_KEY in results_json:
571 archive_version = results_json[self.VERSION_KEY]
572 if archive_version == self.VERSION:
578 if archive_version == 3:
579 num_results = len(results_json.values())
580 for builder, results in results_json.iteritems():
581 self._convert_tests_to_trie(results)
583 results_json[self.VERSION_KEY] = self.VERSION
585 def _convert_tests_to_trie(self, results):
586 if not self.TESTS in results:
589 test_results = results[self.TESTS]
590 test_results_trie = {}
591 for test in test_results.iterkeys():
592 single_test_result = test_results[test]
593 add_path_to_trie(test, single_test_result, test_results_trie)
595 results[self.TESTS] = test_results_trie
597 def _populate_results_and_times_json(self, results_and_times):
598 results_and_times[self.RESULTS] = []
599 results_and_times[self.TIMES] = []
600 return results_and_times
602 def _create_results_for_builder_json(self):
603 results_for_builder = {}
604 results_for_builder[self.TESTS] = {}
605 return results_for_builder
607 def _remove_items_over_max_number_of_builds(self, encoded_list):
608 """Removes items from the run-length encoded list after the final
609 item that exceeds the max number of builds to track.
612 encoded_results: run-length encoded results. An array of arrays, e.g.
613 [[3,'A'],[1,'Q']] encodes AAAQ.
617 for result in encoded_list:
618 num_builds = num_builds + result[0]
620 if num_builds > self.MAX_NUMBER_OF_BUILD_RESULTS_TO_LOG:
621 return encoded_list[:index]
624 def _normalize_results_json(self, test, test_name, tests):
625 """ Prune tests where all runs pass or tests that no longer exist and
626 truncate all results to maxNumberOfBuilds.
629 test: ResultsAndTimes object for this test.
630 test_name: Name of the test.
631 tests: The JSON object with all the test results for this builder.
633 test[self.RESULTS] = self._remove_items_over_max_number_of_builds(
635 test[self.TIMES] = self._remove_items_over_max_number_of_builds(
638 is_all_pass = self._is_results_all_of_type(test[self.RESULTS],
640 is_all_no_data = self._is_results_all_of_type(test[self.RESULTS],
642 max_time = max([time[1] for time in test[self.TIMES]])
644 # Remove all passes/no-data from the results to reduce noise and
645 # filesize. If a test passes every run, but takes > MIN_TIME to run,
646 # don't throw away the data.
647 if is_all_no_data or (is_all_pass and max_time <= self.MIN_TIME):
650 def _is_results_all_of_type(self, results, type):
651 """Returns whether all the results are of the given type
652 (e.g. all passes)."""
653 return len(results) == 1 and results[0][1] == type
656 # Left here not to break anything.
657 class JSONResultsGenerator(JSONResultsGeneratorBase):