# Copyright (C) 2010 Google Inc. All rights reserved. # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are # met: # # * Redistributions of source code must retain the above copyright # notice, this list of conditions and the following disclaimer. # * 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. # * Neither the name of Google Inc. nor the names of its # contributors may be used to endorse or promote products derived from # this software without specific prior written permission. # # 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 json import logging from webkitpy.common.iteration_compatibility import iteritems, iterkeys # A JSON results generator for generic tests. # FIXME: move this code out of the layout_package directory. _log = logging.getLogger(__name__) _JSON_PREFIX = "ADD_RESULTS(" _JSON_SUFFIX = ");" def has_json_wrapper(string): return string.startswith(_JSON_PREFIX) and string.endswith(_JSON_SUFFIX) def strip_json_wrapper(json_content): # FIXME: Kill this code once the server returns json instead of jsonp. if has_json_wrapper(json_content): return json_content[len(_JSON_PREFIX):len(json_content) - len(_JSON_SUFFIX)] return json_content def load_json(filesystem, file_path): content = filesystem.read_text_file(file_path) content = strip_json_wrapper(content) return json.loads(content) def write_json(filesystem, json_object, file_path, callback=None): # Specify separators in order to get compact encoding. json_string = json.dumps(json_object, separators=(',', ':')) if callback: json_string = callback + "(" + json_string + ");" filesystem.write_text_file(file_path, json_string) def convert_trie_to_flat_paths(trie, prefix=None): """Converts the directory structure in the given trie to flat paths, prepending a prefix to each.""" result = {} for name, data in iteritems(trie): if prefix: name = prefix + "/" + name if len(data) and not "results" in data: result.update(convert_trie_to_flat_paths(data, name)) else: result[name] = data return result def add_path_to_trie(path, value, trie): """Inserts a single flat directory path and associated value into a directory trie structure.""" if not "/" in path: trie[path] = value return directory, slash, rest = path.partition("/") if not directory in trie: trie[directory] = {} add_path_to_trie(rest, value, trie[directory]) def test_timings_trie(port, individual_test_timings): """Breaks a test name into chunks by directory and puts the test time as a value in the lowest part, e.g. foo/bar/baz.html: 1ms foo/bar/baz1.html: 3ms becomes foo: { bar: { baz.html: 1, baz1.html: 3 } } """ trie = {} for test_result in individual_test_timings: test = test_result.test_name add_path_to_trie(test, int(1000 * test_result.test_run_time), trie) return trie def _add_perf_metric_for_test(path, time, tests, depth, depth_limit): """ Aggregate test time to result for a given test at a specified depth_limit. """ if not "/" in path: tests["tests"][path] = { "metrics": { "Time": { "current": [time], }}} return directory, slash, rest = path.partition("/") if depth == depth_limit: if directory not in tests["tests"]: tests["tests"][directory] = { "metrics": { "Time": { "current": [time], }}} else: tests["tests"][directory]["metrics"]["Time"]["current"][0] += time return else: if directory not in tests["tests"]: tests["tests"][directory] = { "metrics": { "Time": ["Total", "Arithmetic"], }, "tests": {} } _add_perf_metric_for_test(rest, time, tests["tests"][directory], depth + 1, depth_limit) def perf_metrics_for_test(run_time, individual_test_timings): """ Output two performace metrics 1. run time, which is how much time consumed by the layout tests script 2. run time of first-level and second-level of test directories """ total_run_time = 0 for test_result in individual_test_timings: total_run_time += int(1000 * test_result.test_run_time) perf_metric = { "layout_tests": { "metrics": { "Time": ["Total", "Arithmetic"], }, "tests": {} }, "layout_tests_run_time": { "metrics": { "Time": {"current": [run_time]}, }}} for test_result in individual_test_timings: test = test_result.test_name # for now, we only send two levels of directories _add_perf_metric_for_test(test, int(1000 * test_result.test_run_time), perf_metric["layout_tests"], 1, 2) return perf_metric # FIXME: We already have a TestResult class in test_results.py class TestResult(object): """A simple class that represents a single test result.""" # Test modifier constants. (NONE, FAILS, FLAKY, DISABLED) = range(4) def __init__(self, test, failed=False, elapsed_time=0): self.test_name = test self.failed = failed self.test_run_time = elapsed_time test_name = test try: test_name = test.split('.')[1] except IndexError: _log.warn("Invalid test name: %s.", test) pass if test_name.startswith('FAILS_'): self.modifier = self.FAILS elif test_name.startswith('FLAKY_'): self.modifier = self.FLAKY elif test_name.startswith('DISABLED_'): self.modifier = self.DISABLED else: self.modifier = self.NONE def fixable(self): return self.failed or self.modifier == self.DISABLED class JSONResultsGenerator(object): """A JSON results generator for generic tests.""" MAX_NUMBER_OF_BUILD_RESULTS_TO_LOG = 750 # Min time (seconds) that will be added to the JSON. MIN_TIME = 1 # Note that in non-chromium tests those chars are used to indicate # test modifiers (FAILS, FLAKY, etc) but not actual test results. PASS_RESULT = "P" SKIP_RESULT = "X" FAIL_RESULT = "F" FLAKY_RESULT = "L" NO_DATA_RESULT = "N" MODIFIER_TO_CHAR = {TestResult.NONE: PASS_RESULT, TestResult.DISABLED: SKIP_RESULT, TestResult.FAILS: FAIL_RESULT, TestResult.FLAKY: FLAKY_RESULT} RESULTS = "results" TIMES = "times" TIME = "secondsSinceEpoch" TESTS = "tests" FIXABLE_COUNT = "fixableCount" FIXABLE = "fixableCounts" ALL_FIXABLE_COUNT = "allFixableCount" RESULTS_FILENAME = "results.json" TIMES_MS_FILENAME = "times_ms.json" INCREMENTAL_RESULTS_FILENAME = "incremental_results.json" URL_FOR_TEST_LIST_JSON = "http://%s/testfile?builder=%s&name=%s&testlistjson=1&testtype=%s&master=%s" def __init__(self, port, results_file_base_path, test_results_map, svn_repositories=None, test_type=""): """Modifies the results.json file. Grabs it off the archive directory if it is not found locally. Args port: port-specific wrapper results_file_base_path: Absolute path to the directory containing the results json file. test_results_map: A dictionary that maps test_name to TestResult. svn_repositories: A (json_field_name, svn_path) pair for SVN repositories that tests rely on. The SVN revision will be included in the JSON with the given json_field_name. test_type: test type string (e.g. 'layout-tests'). """ self._port = port self._filesystem = port._filesystem self._executive = port._executive self._results_directory = results_file_base_path self._test_results_map = test_results_map self._test_results = test_results_map.values() self._svn_repositories = svn_repositories if not self._svn_repositories: self._svn_repositories = {} self._test_type = test_type self._archived_results = None def generate_json_output(self): json_object = self.get_json() if not json_object: return False file_path = self._filesystem.join(self._results_directory, self.INCREMENTAL_RESULTS_FILENAME) write_json(self._filesystem, json_object, file_path) return True def generate_times_ms_file(self): times = test_timings_trie(self._port, self._test_results_map.values()) file_path = self._filesystem.join(self._results_directory, self.TIMES_MS_FILENAME) write_json(self._filesystem, times, file_path) def get_json(self): """Gets the results for the results.json file.""" results_for_builder = self._create_results_for_builder_json() self._insert_failure_summaries(results_for_builder) # Update the all failing tests with result type and time. tests = results_for_builder[self.TESTS] all_failing_tests = self._get_failed_test_names() all_failing_tests.update(convert_trie_to_flat_paths(tests)) for test in all_failing_tests: self._insert_test_time_and_result(test, tests) return dict( results=results_for_builder, ) def set_archived_results(self, archived_results): self._archived_results = archived_results def _get_test_timing(self, test_name): """Returns test timing data (elapsed time) in second for the given test_name.""" if test_name in self._test_results_map: # Floor for now to get time in seconds. return int(self._test_results_map[test_name].test_run_time) return 0 def _get_failed_test_names(self): """Returns a set of failed test names.""" return set([r.test_name for r in self._test_results if r.failed]) def _get_modifier_char(self, test_name): """Returns a single char (e.g. SKIP_RESULT, FAIL_RESULT, PASS_RESULT, NO_DATA_RESULT, etc) that indicates the test modifier for the given test_name. """ if test_name not in self._test_results_map: return self.__class__.NO_DATA_RESULT test_result = self._test_results_map[test_name] if test_result.modifier in self.MODIFIER_TO_CHAR.keys(): return self.MODIFIER_TO_CHAR[test_result.modifier] return self.__class__.PASS_RESULT def _get_result_char(self, test_name): """Returns a single char (e.g. SKIP_RESULT, FAIL_RESULT, PASS_RESULT, NO_DATA_RESULT, etc) that indicates the test result for the given test_name. """ if test_name not in self._test_results_map: return self.__class__.NO_DATA_RESULT test_result = self._test_results_map[test_name] if test_result.modifier == TestResult.DISABLED: return self.__class__.SKIP_RESULT if test_result.failed: return self.__class__.FAIL_RESULT return self.__class__.PASS_RESULT def _insert_failure_summaries(self, results_for_builder): """Inserts aggregate pass/failure statistics into the JSON. This method reads self._test_results and generates FIXABLE, FIXABLE_COUNT and ALL_FIXABLE_COUNT entries. Args: results_for_builder: Dictionary containing the test results for a single builder. """ # Insert the number of tests that failed or skipped. fixable_count = len([r for r in self._test_results if r.fixable()]) self._insert_item_into_raw_list(results_for_builder, fixable_count, self.FIXABLE_COUNT) # Create a test modifiers (FAILS, FLAKY etc) summary dictionary. entry = {} for test_name in iterkeys(self._test_results_map): result_char = self._get_modifier_char(test_name) entry[result_char] = entry.get(result_char, 0) + 1 # Insert the pass/skip/failure summary dictionary. self._insert_item_into_raw_list(results_for_builder, entry, self.FIXABLE) # Insert the number of all the tests that are supposed to pass. all_test_count = len(self._test_results) self._insert_item_into_raw_list(results_for_builder, all_test_count, self.ALL_FIXABLE_COUNT) def _insert_item_into_raw_list(self, results_for_builder, item, key): """Inserts the item into the list with the given key in the results for this builder. Creates the list if no such list exists. Args: results_for_builder: Dictionary containing the test results for a single builder. item: Number or string to insert into the list. key: Key in results_for_builder for the list to insert into. """ if key in results_for_builder: raw_list = results_for_builder[key] else: raw_list = [] raw_list.insert(0, item) raw_list = raw_list[:self.MAX_NUMBER_OF_BUILD_RESULTS_TO_LOG] results_for_builder[key] = raw_list def _insert_item_run_length_encoded(self, item, encoded_results): """Inserts the item into the run-length encoded results. Args: item: String or number to insert. encoded_results: run-length encoded results. An array of arrays, e.g. [[3,'A'],[1,'Q']] encodes AAAQ. """ if len(encoded_results) and item == encoded_results[0][1]: num_results = encoded_results[0][0] if num_results <= self.MAX_NUMBER_OF_BUILD_RESULTS_TO_LOG: encoded_results[0][0] = num_results + 1 else: # Use a list instead of a class for the run-length encoding since # we want the serialized form to be concise. encoded_results.insert(0, [1, item]) def _insert_test_time_and_result(self, test_name, tests): """ Insert a test item with its results to the given tests dictionary. Args: tests: Dictionary containing test result entries. """ result = self._get_result_char(test_name) time = self._get_test_timing(test_name) this_test = tests for segment in test_name.split("/"): if segment not in this_test: this_test[segment] = {} this_test = this_test[segment] if not len(this_test): self._populate_results_and_times_json(this_test) if self.RESULTS in this_test: self._insert_item_run_length_encoded(result, this_test[self.RESULTS]) else: this_test[self.RESULTS] = [[1, result]] if self.TIMES in this_test: self._insert_item_run_length_encoded(time, this_test[self.TIMES]) else: this_test[self.TIMES] = [[1, time]] def _populate_results_and_times_json(self, results_and_times): results_and_times[self.RESULTS] = [] results_and_times[self.TIMES] = [] return results_and_times def _create_results_for_builder_json(self): results_for_builder = {} results_for_builder[self.TESTS] = {} return results_for_builder