| OLD | NEW |
| (Empty) |
| 1 # Copyright 2016 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 """A class for updating layout test expectations when updating w3c tests. | |
| 6 | |
| 7 Specifically, this class fetches results from try bots for the current CL, and: | |
| 8 1. Downloads new baseline files for any tests that can be rebaselined. | |
| 9 2. Updates the generic TestExpectations file for any other failing tests. | |
| 10 | |
| 11 This is used as part of the w3c test auto-import process. | |
| 12 """ | |
| 13 | |
| 14 import argparse | |
| 15 import copy | |
| 16 import logging | |
| 17 | |
| 18 from webkitpy.common.net.git_cl import GitCL | |
| 19 from webkitpy.common.net.rietveld import Rietveld | |
| 20 from webkitpy.common.webkit_finder import WebKitFinder | |
| 21 from webkitpy.layout_tests.models.test_expectations import TestExpectationLine | |
| 22 from webkitpy.w3c.test_parser import TestParser | |
| 23 | |
| 24 _log = logging.getLogger(__name__) | |
| 25 | |
| 26 MARKER_COMMENT = '# ====== New tests from w3c-test-autoroller added here ======' | |
| 27 | |
| 28 | |
| 29 class W3CExpectationsLineAdder(object): | |
| 30 | |
| 31 def __init__(self, host): | |
| 32 self.host = host | |
| 33 self.host.initialize_scm() | |
| 34 self.finder = WebKitFinder(self.host.filesystem) | |
| 35 | |
| 36 def run(self, args=None): | |
| 37 """Downloads text new baselines and adds test expectations lines.""" | |
| 38 parser = argparse.ArgumentParser(description=__doc__) | |
| 39 parser.add_argument('-v', '--verbose', action='store_true', help='More v
erbose logging.') | |
| 40 args = parser.parse_args(args) | |
| 41 | |
| 42 log_level = logging.DEBUG if args.verbose else logging.INFO | |
| 43 logging.basicConfig(level=log_level, format='%(message)s') | |
| 44 | |
| 45 issue_number = self.get_issue_number() | |
| 46 if issue_number == 'None': | |
| 47 _log.error('No issue on current branch.') | |
| 48 return 1 | |
| 49 | |
| 50 rietveld = Rietveld(self.host.web) | |
| 51 builds = rietveld.latest_try_jobs(issue_number, self.get_try_bots()) | |
| 52 _log.debug('Latest try jobs: %r', builds) | |
| 53 if not builds: | |
| 54 _log.error('No try job information was collected.') | |
| 55 return 1 | |
| 56 | |
| 57 # Here we build up a dict of failing test results for all platforms. | |
| 58 test_expectations = {} | |
| 59 for build in builds: | |
| 60 platform_results = self.get_failing_results_dict(build) | |
| 61 test_expectations = self.merge_dicts(test_expectations, platform_res
ults) | |
| 62 | |
| 63 # And then we merge results for different platforms that had the same re
sults. | |
| 64 for test_name, platform_result in test_expectations.iteritems(): | |
| 65 # platform_result is a dict mapping platforms to results. | |
| 66 test_expectations[test_name] = self.merge_same_valued_keys(platform_
result) | |
| 67 | |
| 68 test_expectations = self.download_text_baselines(test_expectations) | |
| 69 test_expectation_lines = self.create_line_list(test_expectations) | |
| 70 self.write_to_test_expectations(test_expectation_lines) | |
| 71 return 0 | |
| 72 | |
| 73 def get_issue_number(self): | |
| 74 """Returns current CL number. Can be replaced in unit tests.""" | |
| 75 return GitCL(self.host).get_issue_number() | |
| 76 | |
| 77 def get_try_bots(self): | |
| 78 """Returns try bot names. Can be replaced in unit tests.""" | |
| 79 return self.host.builders.all_try_builder_names() | |
| 80 | |
| 81 def get_failing_results_dict(self, build): | |
| 82 """Returns a nested dict of failing test results. | |
| 83 | |
| 84 Retrieves a full list of layout test results from a builder result URL. | |
| 85 Collects the builder name, platform and a list of tests that did not | |
| 86 run as expected. | |
| 87 | |
| 88 Args: | |
| 89 build: A Build object. | |
| 90 | |
| 91 Returns: | |
| 92 A dictionary with the structure: { | |
| 93 'key': { | |
| 94 'expected': 'TIMEOUT', | |
| 95 'actual': 'CRASH', | |
| 96 'bug': 'crbug.com/11111' | |
| 97 } | |
| 98 } | |
| 99 If there are no failing results or no results could be fetched, | |
| 100 this will return an empty dict. | |
| 101 """ | |
| 102 layout_test_results = self.host.buildbot.fetch_results(build) | |
| 103 if layout_test_results is None: | |
| 104 _log.warning('No results for build %s', build) | |
| 105 return {} | |
| 106 platform = self.host.builders.port_name_for_builder_name(build.builder_n
ame) | |
| 107 test_results = layout_test_results.didnt_run_as_expected_results() | |
| 108 failing_results_dict = self.generate_results_dict(platform, test_results
) | |
| 109 return failing_results_dict | |
| 110 | |
| 111 def generate_results_dict(self, full_port_name, test_results): | |
| 112 """Makes a dict with results for one platform. | |
| 113 | |
| 114 Args: | |
| 115 full_port_name: The full port name, e.g. "win-win10". | |
| 116 test_results: A list of LayoutTestResult objects. | |
| 117 | |
| 118 Returns: | |
| 119 A dict mapping to platform string (e.g. "Win10") to a dict with | |
| 120 the results for that test and that platform. | |
| 121 """ | |
| 122 platform = self._port_name_to_platform_specifier(full_port_name) | |
| 123 test_dict = {} | |
| 124 for result in test_results: | |
| 125 test_dict[result.test_name()] = { | |
| 126 platform: { | |
| 127 'expected': result.expected_results(), | |
| 128 'actual': result.actual_results(), | |
| 129 'bug': 'crbug.com/626703' | |
| 130 }} | |
| 131 return test_dict | |
| 132 | |
| 133 def _port_name_to_platform_specifier(self, port_name): | |
| 134 """Maps a port name to the string used in test expectations lines. | |
| 135 | |
| 136 For example: | |
| 137 linux-trusty -> Trusty | |
| 138 mac-mac10.11 -> Mac10.11. | |
| 139 """ | |
| 140 # TODO(qyearsley): Do this in a more robust way with Port classes. | |
| 141 if '-' in port_name: | |
| 142 return port_name[port_name.find('-') + 1:].capitalize() | |
| 143 return port_name | |
| 144 | |
| 145 def merge_dicts(self, target, source, path=None): | |
| 146 """Recursively merges nested dictionaries. | |
| 147 | |
| 148 Args: | |
| 149 target: First dictionary, which is updated based on source. | |
| 150 source: Second dictionary, not modified. | |
| 151 | |
| 152 Returns: | |
| 153 An updated target dictionary. | |
| 154 """ | |
| 155 path = path or [] | |
| 156 for key in source: | |
| 157 if key in target: | |
| 158 if (isinstance(target[key], dict)) and isinstance(source[key], d
ict): | |
| 159 self.merge_dicts(target[key], source[key], path + [str(key)]
) | |
| 160 elif target[key] == source[key]: | |
| 161 pass | |
| 162 else: | |
| 163 raise ValueError('The key: %s already exist in the target di
ctionary.' % '.'.join(path)) | |
| 164 else: | |
| 165 target[key] = source[key] | |
| 166 return target | |
| 167 | |
| 168 def merge_same_valued_keys(self, dictionary): | |
| 169 """Merges keys in dictionary with same value. | |
| 170 | |
| 171 Traverses through a dict and compares the values of keys to one another. | |
| 172 If the values match, the keys are combined to a tuple and the previous | |
| 173 keys are removed from the dict. | |
| 174 | |
| 175 Args: | |
| 176 dictionary: A dictionary with a dictionary as the value. | |
| 177 | |
| 178 Returns: | |
| 179 A new dictionary with updated keys to reflect matching values of key
s. | |
| 180 Example: { | |
| 181 'one': {'foo': 'bar'}, | |
| 182 'two': {'foo': 'bar'}, | |
| 183 'three': {'foo': 'bar'} | |
| 184 } | |
| 185 is converted to a new dictionary with that contains | |
| 186 {('one', 'two', 'three'): {'foo': 'bar'}} | |
| 187 """ | |
| 188 merged_dict = {} | |
| 189 matching_value_keys = set() | |
| 190 keys = sorted(dictionary.keys()) | |
| 191 while keys: | |
| 192 current_key = keys[0] | |
| 193 found_match = False | |
| 194 if current_key == keys[-1]: | |
| 195 merged_dict[current_key] = dictionary[current_key] | |
| 196 keys.remove(current_key) | |
| 197 break | |
| 198 | |
| 199 for next_item in keys[1:]: | |
| 200 if dictionary[current_key] == dictionary[next_item]: | |
| 201 found_match = True | |
| 202 matching_value_keys.update([current_key, next_item]) | |
| 203 | |
| 204 if next_item == keys[-1]: | |
| 205 if found_match: | |
| 206 merged_dict[tuple(matching_value_keys)] = dictionary[cur
rent_key] | |
| 207 keys = [k for k in keys if k not in matching_value_keys] | |
| 208 else: | |
| 209 merged_dict[current_key] = dictionary[current_key] | |
| 210 keys.remove(current_key) | |
| 211 matching_value_keys = set() | |
| 212 return merged_dict | |
| 213 | |
| 214 def get_expectations(self, results): | |
| 215 """Returns a set of test expectations for a given test dict. | |
| 216 | |
| 217 Returns a set of one or more test expectations based on the expected | |
| 218 and actual results of a given test name. | |
| 219 | |
| 220 Args: | |
| 221 results: A dictionary that maps one test to its results. Example: | |
| 222 { | |
| 223 'test_name': { | |
| 224 'expected': 'PASS', | |
| 225 'actual': 'FAIL', | |
| 226 'bug': 'crbug.com/11111' | |
| 227 } | |
| 228 } | |
| 229 | |
| 230 Returns: | |
| 231 A set of one or more test expectation strings with the first letter | |
| 232 capitalized. Example: set(['Failure', 'Timeout']). | |
| 233 """ | |
| 234 expectations = set() | |
| 235 failure_types = ['TEXT', 'FAIL', 'IMAGE+TEXT', 'IMAGE', 'AUDIO', 'MISSIN
G', 'LEAK'] | |
| 236 test_expectation_types = ['SLOW', 'TIMEOUT', 'CRASH', 'PASS', 'REBASELIN
E', 'NEEDSREBASELINE', 'NEEDSMANUALREBASELINE'] | |
| 237 for expected in results['expected'].split(): | |
| 238 for actual in results['actual'].split(): | |
| 239 if expected in test_expectation_types and actual in failure_type
s: | |
| 240 expectations.add('Failure') | |
| 241 if expected in failure_types and actual in test_expectation_type
s: | |
| 242 expectations.add(actual.capitalize()) | |
| 243 if expected in test_expectation_types and actual in test_expecta
tion_types: | |
| 244 expectations.add(actual.capitalize()) | |
| 245 return expectations | |
| 246 | |
| 247 def create_line_list(self, merged_results): | |
| 248 """Creates list of test expectations lines. | |
| 249 | |
| 250 Traverses through the given |merged_results| dictionary and parses the | |
| 251 value to create one test expectations line per key. | |
| 252 | |
| 253 Args: | |
| 254 merged_results: A merged_results with the format: | |
| 255 { | |
| 256 'test_name': { | |
| 257 'platform': { | |
| 258 'expected: 'PASS', | |
| 259 'actual': 'FAIL', | |
| 260 'bug': 'crbug.com/11111' | |
| 261 } | |
| 262 } | |
| 263 } | |
| 264 | |
| 265 Returns: | |
| 266 A list of test expectations lines with the format: | |
| 267 ['BUG_URL [PLATFORM(S)] TEST_MAME [EXPECTATION(S)]'] | |
| 268 """ | |
| 269 line_list = [] | |
| 270 for test_name, platform_results in merged_results.iteritems(): | |
| 271 for platform in platform_results: | |
| 272 if test_name.startswith('external'): | |
| 273 platform_list = [] | |
| 274 bug = [] | |
| 275 expectations = [] | |
| 276 if isinstance(platform, tuple): | |
| 277 platform_list = list(platform) | |
| 278 else: | |
| 279 platform_list.append(platform) | |
| 280 bug.append(platform_results[platform]['bug']) | |
| 281 expectations = self.get_expectations(platform_results[platfo
rm]) | |
| 282 line = '%s [ %s ] %s [ %s ]' % (bug[0], ' '.join(platform_li
st), test_name, ' '.join(expectations)) | |
| 283 line_list.append(str(line)) | |
| 284 return line_list | |
| 285 | |
| 286 def write_to_test_expectations(self, line_list): | |
| 287 """Writes to TestExpectations. | |
| 288 | |
| 289 The place in the file where the new lines are inserted is after a | |
| 290 marker comment line. If this marker comment line is not found, it will | |
| 291 be added to the end of the file. | |
| 292 | |
| 293 Args: | |
| 294 line_list: A list of lines to add to the TestExpectations file. | |
| 295 """ | |
| 296 _log.info('Lines to write to TestExpectations:') | |
| 297 for line in line_list: | |
| 298 _log.info(' %s', line) | |
| 299 port = self.host.port_factory.get() | |
| 300 expectations_file_path = port.path_to_generic_test_expectations_file() | |
| 301 file_contents = self.host.filesystem.read_text_file(expectations_file_pa
th) | |
| 302 marker_comment_index = file_contents.find(MARKER_COMMENT) | |
| 303 line_list = [line for line in line_list if self._test_name_from_expectat
ion_string(line) not in file_contents] | |
| 304 if not line_list: | |
| 305 return | |
| 306 if marker_comment_index == -1: | |
| 307 file_contents += '\n%s\n' % MARKER_COMMENT | |
| 308 file_contents += '\n'.join(line_list) | |
| 309 else: | |
| 310 end_of_marker_line = (file_contents[marker_comment_index:].find('\n'
)) + marker_comment_index | |
| 311 file_contents = file_contents[:end_of_marker_line + 1] + '\n'.join(l
ine_list) + file_contents[end_of_marker_line:] | |
| 312 self.host.filesystem.write_text_file(expectations_file_path, file_conten
ts) | |
| 313 | |
| 314 @staticmethod | |
| 315 def _test_name_from_expectation_string(expectation_string): | |
| 316 return TestExpectationLine.tokenize_line(filename='', expectation_string
=expectation_string, line_number=0).name | |
| 317 | |
| 318 def download_text_baselines(self, tests_results): | |
| 319 """Fetches new baseline files for tests that should be rebaselined. | |
| 320 | |
| 321 Invokes `webkit-patch rebaseline-cl` in order to download new baselines | |
| 322 (-expected.txt files) for testharness.js tests that did not crash or | |
| 323 time out. Then, the platform-specific test is removed from the overall | |
| 324 failure test dictionary. | |
| 325 | |
| 326 Args: | |
| 327 tests_results: A dict mapping test name to platform to test results. | |
| 328 | |
| 329 Returns: | |
| 330 An updated tests_results dictionary without the platform-specific | |
| 331 testharness.js tests that required new baselines to be downloaded | |
| 332 from `webkit-patch rebaseline-cl`. | |
| 333 """ | |
| 334 tests_to_rebaseline, tests_results = self.get_tests_to_rebaseline(tests_
results) | |
| 335 _log.info('Tests to rebaseline:') | |
| 336 for test in tests_to_rebaseline: | |
| 337 _log.info(' %s', test) | |
| 338 if tests_to_rebaseline: | |
| 339 webkit_patch = self.host.filesystem.join( | |
| 340 self.finder.chromium_base(), self.finder.webkit_base(), self.fin
der.path_to_script('webkit-patch')) | |
| 341 self.host.executive.run_command([ | |
| 342 'python', | |
| 343 webkit_patch, | |
| 344 'rebaseline-cl', | |
| 345 '--verbose', | |
| 346 '--no-trigger-jobs', | |
| 347 ] + tests_to_rebaseline) | |
| 348 return tests_results | |
| 349 | |
| 350 def get_tests_to_rebaseline(self, test_results): | |
| 351 """Returns a list of tests to download new baselines for. | |
| 352 | |
| 353 Creates a list of tests to rebaseline depending on the tests' platform- | |
| 354 specific results. In general, this will be non-ref tests that failed | |
| 355 due to a baseline mismatch (rather than crash or timeout). | |
| 356 | |
| 357 Args: | |
| 358 test_results: A dictionary of failing test results, mapping tests | |
| 359 to platforms to result dicts. | |
| 360 | |
| 361 Returns: | |
| 362 A pair: A set of tests to be rebaselined, and a modified copy of | |
| 363 the test results dictionary. The tests to be rebaselined should | |
| 364 include testharness.js tests that failed due to a baseline mismatch. | |
| 365 """ | |
| 366 test_results = copy.deepcopy(test_results) | |
| 367 tests_to_rebaseline = set() | |
| 368 for test_path in test_results: | |
| 369 if not (self.is_js_test(test_path) and test_results.get(test_path)): | |
| 370 continue | |
| 371 for platform in test_results[test_path].keys(): | |
| 372 if test_results[test_path][platform]['actual'] not in ['CRASH',
'TIMEOUT']: | |
| 373 del test_results[test_path][platform] | |
| 374 tests_to_rebaseline.add(test_path) | |
| 375 return sorted(tests_to_rebaseline), test_results | |
| 376 | |
| 377 def is_js_test(self, test_path): | |
| 378 """Checks whether a given file is a testharness.js test. | |
| 379 | |
| 380 Args: | |
| 381 test_path: A file path relative to the layout tests directory. | |
| 382 This might correspond to a deleted file or a non-test. | |
| 383 """ | |
| 384 absolute_path = self.host.filesystem.join(self.finder.layout_tests_dir()
, test_path) | |
| 385 test_parser = TestParser(absolute_path, self.host) | |
| 386 if not test_parser.test_doc: | |
| 387 return False | |
| 388 return test_parser.is_jstest() | |
| OLD | NEW |