1 # Copyright (c) 2012 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 """Helper functions for the layout test analyzer.""" 6 7 from datetime import datetime 8 from email.mime.multipart import MIMEMultipart 9 from email.mime.text import MIMEText 10 import fileinput 11 import os 12 import pickle 13 import re 14 import smtplib 15 import socket 16 import sys 17 import time 18 19 from bug import Bug 20 from test_expectations_history import TestExpectationsHistory 21 22 DEFAULT_TEST_EXPECTATION_PATH = ('trunk/LayoutTests/TestExpectations') 23 LEGACY_DEFAULT_TEST_EXPECTATION_PATH = ( 24 'trunk/LayoutTests/platform/chromium/test_expectations.txt') 25 REVISION_LOG_URL = ('http://build.chromium.org/f/chromium/perf/dashboard/ui/' 26 'changelog_blink.html?url=/trunk/LayoutTests/%s&range=%d:%d') 27 DEFAULT_REVISION_VIEW_URL = 'http://src.chromium.org/viewvc/blink?revision=%s' 28 29 30 class AnalyzerResultMap: 31 """A class to deal with joined result produed by the analyzer. 32 33 The join is done between layouttests and the test_expectations object 34 (based on the test expectation file). The instance variable |result_map| 35 contains the following keys: 'whole','skip','nonskip'. The value of 'whole' 36 contains information about all layouttests. The value of 'skip' contains 37 information about skipped layouttests where it has 'SKIP' in its entry in 38 the test expectation file. The value of 'nonskip' contains all information 39 about non skipped layout tests, which are in the test expectation file but 40 not skipped. The information is exactly same as the one parsed by the 41 analyzer. 42 """ 43 44 def __init__(self, test_info_map): 45 """Initialize the AnalyzerResultMap based on test_info_map. 46 47 Test_info_map contains all layouttest information. The job here is to 48 classify them as 'whole', 'skip' or 'nonskip' based on that information. 49 50 Args: 51 test_info_map: the result map of |layouttests.JoinWithTestExpectation|. 52 The key of the map is test name such as 'media/media-foo.html'. 53 The value of the map is a map that contains the following keys: 54 'desc'(description), 'te_info' (test expectation information), 55 which is a list of test expectation information map. The key of the 56 test expectation information map is test expectation keywords such 57 as "SKIP" and other keywords (for full list of keywords, please 58 refer to |test_expectations.ALL_TE_KEYWORDS|). 59 """ 60 self.result_map = {} 61 self.result_map['whole'] = {} 62 self.result_map['skip'] = {} 63 self.result_map['nonskip'] = {} 64 if test_info_map: 65 for (k, value) in test_info_map.iteritems(): 66 self.result_map['whole'][k] = value 67 if 'te_info' in value: 68 # Don't count SLOW PASS, WONTFIX, or ANDROID tests as failures. 69 if any([True for x in value['te_info'] if set(x.keys()) == 70 set(['SLOW', 'PASS', 'Bugs', 'Comments', 'Platforms']) or 71 'WONTFIX' in x or x['Platforms'] == ['ANDROID']]): 72 continue 73 if any([True for x in value['te_info'] if 'SKIP' in x]): 74 self.result_map['skip'][k] = value 75 else: 76 self.result_map['nonskip'][k] = value 77 78 @staticmethod 79 def GetDiffString(diff_map_element, type_str): 80 """Get difference string out of diff map element. 81 82 The difference string shows difference between two analyzer results 83 (for example, a result for now and a result for sometime in the past) 84 in HTML format (with colors). This is used for generating email messages. 85 86 Args: 87 diff_map_element: An element of the compared map generated by 88 |CompareResultMaps()|. The element has two lists of test cases. One 89 is for test names that are in the current result but NOT in the 90 previous result. The other is for test names that are in the previous 91 results but NOT in the current result. Please refer to comments in 92 |CompareResultMaps()| for details. 93 type_str: a string indicating the test group to which |diff_map_element| 94 belongs; used for color determination. Must be 'whole', 'skip', or 95 'nonskip'. 96 97 Returns: 98 a string in HTML format (with colors) to show difference between two 99 analyzer results. 100 """ 101 if not diff_map_element[0] and not diff_map_element[1]: 102 return 'No Change' 103 color = '' 104 diff = len(diff_map_element[0]) - len(diff_map_element[1]) 105 if diff > 0 and type_str != 'whole': 106 color = 'red' 107 else: 108 color = 'green' 109 diff_sign = '' 110 if diff > 0: 111 diff_sign = '+' 112 if not diff: 113 whole_str = 'No Change' 114 else: 115 whole_str = '<font color="%s">%s%d</font>' % (color, diff_sign, diff) 116 colors = ['red', 'green'] 117 if type_str == 'whole': 118 # Bug 107773 - when we increase the number of tests, 119 # the name of the tests are in red, it should be green 120 # since it is good thing. 121 colors = ['green', 'red'] 122 str1 = '' 123 for (name, _) in diff_map_element[0]: 124 str1 += '<font color="%s">%s,</font>' % (colors[0], name) 125 str2 = '' 126 for (name, _) in diff_map_element[1]: 127 str2 += '<font color="%s">%s,</font>' % (colors[1], name) 128 if str1 or str2: 129 whole_str += ':' 130 if str1: 131 whole_str += str1 132 if str2: 133 whole_str += str2 134 # Remove the last occurrence of ','. 135 whole_str = ''.join(whole_str.rsplit(',', 1)) 136 return whole_str 137 138 def GetPassingRate(self): 139 """Get passing rate. 140 141 Returns: 142 layout test passing rate of this result in percent. 143 144 Raises: 145 ValueEror when the number of tests in test group "whole" is equal 146 or less than that of "skip". 147 """ 148 delta = len(self.result_map['whole'].keys()) - ( 149 len(self.result_map['skip'].keys())) 150 if delta <= 0: 151 raise ValueError('The number of tests in test group "whole" is equal or ' 152 'less than that of "skip"') 153 return 100 - len(self.result_map['nonskip'].keys()) * 100.0 / delta 154 155 def ConvertToCSVText(self, current_time): 156 """Convert |self.result_map| into stats and issues text in CSV format. 157 158 Both are used as inputs for Google spreadsheet. 159 160 Args: 161 current_time: a string depicting a time in year-month-day-hour 162 format (e.g., 2011-11-08-16). 163 164 Returns: 165 a tuple of stats and issues_txt 166 stats: analyzer result in CSV format that shows: 167 (current_time, the number of tests, the number of skipped tests, 168 the number of failing tests, passing rate) 169 For example, 170 "2011-11-10-15,204,22,12,94" 171 issues_txt: issues listed in CSV format that shows: 172 (BUGWK or BUGCR, bug number, the test expectation entry, 173 the name of the test) 174 For example, 175 "BUGWK,71543,TIMEOUT PASS,media/media-element-play-after-eos.html, 176 BUGCR,97657,IMAGE CPU MAC TIMEOUT PASS,media/audio-repaint.html," 177 """ 178 stats = ','.join([current_time, str(len(self.result_map['whole'].keys())), 179 str(len(self.result_map['skip'].keys())), 180 str(len(self.result_map['nonskip'].keys())), 181 str(self.GetPassingRate())]) 182 issues_txt = '' 183 for bug_txt, test_info_list in ( 184 self.GetListOfBugsForNonSkippedTests().iteritems()): 185 matches = re.match(r'(BUG(CR|WK))(\d+)', bug_txt) 186 bug_suffix = '' 187 bug_no = '' 188 if matches: 189 bug_suffix = matches.group(1) 190 bug_no = matches.group(3) 191 issues_txt += bug_suffix + ',' + bug_no + ',' 192 for test_info in test_info_list: 193 test_name, te_info = test_info 194 issues_txt += ' '.join(te_info.keys()) + ',' + test_name + ',' 195 issues_txt += '\n' 196 return stats, issues_txt 197 198 def ConvertToString(self, prev_time, diff_map, issue_detail_mode): 199 """Convert this result to HTML display for email. 200 201 Args: 202 prev_time: the previous time string that are compared against. 203 diff_map: the compared map generated by |CompareResultMaps()|. 204 issue_detail_mode: includes the issue details in the output string if 205 this is True. 206 207 Returns: 208 a analyzer result string in HTML format. 209 """ 210 return_str = '' 211 if diff_map: 212 return_str += ( 213 '<b>Statistics (Diff Compared to %s):</b><ul>' 214 '<li>The number of tests: %d (%s)</li>' 215 '<li>The number of failing skipped tests: %d (%s)</li>' 216 '<li>The number of failing non-skipped tests: %d (%s)</li>' 217 '<li>Passing rate: %.2f %%</li></ul>') % ( 218 prev_time, len(self.result_map['whole'].keys()), 219 AnalyzerResultMap.GetDiffString(diff_map['whole'], 'whole'), 220 len(self.result_map['skip'].keys()), 221 AnalyzerResultMap.GetDiffString(diff_map['skip'], 'skip'), 222 len(self.result_map['nonskip'].keys()), 223 AnalyzerResultMap.GetDiffString(diff_map['nonskip'], 'nonskip'), 224 self.GetPassingRate()) 225 if issue_detail_mode: 226 return_str += '<b>Current issues about failing non-skipped tests:</b>' 227 for (bug_txt, test_info_list) in ( 228 self.GetListOfBugsForNonSkippedTests().iteritems()): 229 return_str += '<ul>%s' % Bug(bug_txt) 230 for test_info in test_info_list: 231 (test_name, te_info) = test_info 232 gpu_link = '' 233 if 'GPU' in te_info: 234 gpu_link = 'group=%40ToT%20GPU%20Mesa%20-%20chromium.org&' 235 dashboard_link = ('http://test-results.appspot.com/dashboards/' 236 'flakiness_dashboard.html#%stests=%s') % ( 237 gpu_link, test_name) 238 return_str += '<li><a href="%s">%s</a> (%s) </li>' % ( 239 dashboard_link, test_name, ' '.join( 240 [key for key in te_info.keys() if key != 'Platforms'])) 241 return_str += '</ul>\n' 242 return return_str 243 244 def CompareToOtherResultMap(self, other_result_map): 245 """Compare this result map with the other to see if there are any diff. 246 247 The comparison is done for layouttests which belong to 'whole', 'skip', 248 or 'nonskip'. 249 250 Args: 251 other_result_map: another result map to be compared against the result 252 map of the current object. 253 254 Returns: 255 a map that has 'whole', 'skip' and 'nonskip' as keys. 256 Please refer to |diff_map| in |SendStatusEmail()|. 257 """ 258 comp_result_map = {} 259 for name in ['whole', 'skip', 'nonskip']: 260 if name == 'nonskip': 261 # Look into expectation to get diff only for non-skipped tests. 262 lookIntoTestExpectationInfo = True 263 else: 264 # Otherwise, only test names are compared to get diff. 265 lookIntoTestExpectationInfo = False 266 comp_result_map[name] = GetDiffBetweenMaps( 267 self.result_map[name], other_result_map.result_map[name], 268 lookIntoTestExpectationInfo) 269 return comp_result_map 270 271 @staticmethod 272 def Load(file_path): 273 """Load the object from |file_path| using pickle library. 274 275 Args: 276 file_path: the string path to the file from which to read the result. 277 278 Returns: 279 a AnalyzerResultMap object read from |file_path|. 280 """ 281 file_object = open(file_path) 282 analyzer_result_map = pickle.load(file_object) 283 file_object.close() 284 return analyzer_result_map 285 286 def Save(self, file_path): 287 """Save the object to |file_path| using pickle library. 288 289 Args: 290 file_path: the string path to the file in which to store the result. 291 """ 292 file_object = open(file_path, 'wb') 293 pickle.dump(self, file_object) 294 file_object.close() 295 296 def GetListOfBugsForNonSkippedTests(self): 297 """Get a list of bugs for non-skipped layout tests. 298 299 This is used for generating email content. 300 301 Returns: 302 a mapping from bug modifier text (e.g., BUGCR1111) to a test name and 303 main test information string which excludes comments and bugs. 304 This is used for grouping test names by bug. 305 """ 306 bug_map = {} 307 for (name, value) in self.result_map['nonskip'].iteritems(): 308 for te_info in value['te_info']: 309 main_te_info = {} 310 for k in te_info.keys(): 311 if k != 'Comments' and k != 'Bugs': 312 main_te_info[k] = True 313 if 'Bugs' in te_info: 314 for bug in te_info['Bugs']: 315 if bug not in bug_map: 316 bug_map[bug] = [] 317 bug_map[bug].append((name, main_te_info)) 318 return bug_map 319 320 321 def SendStatusEmail(prev_time, analyzer_result_map, diff_map, 322 receiver_email_address, test_group_name, 323 appended_text_to_email, email_content, rev_str, 324 email_only_change_mode): 325 """Send status email. 326 327 Args: 328 prev_time: the date string such as '2011-10-09-11'. This format has been 329 used in this analyzer. 330 analyzer_result_map: current analyzer result. 331 diff_map: a map that has 'whole', 'skip' and 'nonskip' as keys. 332 The values of the map are the result of |GetDiffBetweenMaps()|. 333 The element has two lists of test cases. One (with index 0) is for 334 test names that are in the current result but NOT in the previous 335 result. The other (with index 1) is for test names that are in the 336 previous results but NOT in the current result. 337 For example (test expectation information is omitted for 338 simplicity), 339 comp_result_map['whole'][0] = ['foo1.html'] 340 comp_result_map['whole'][1] = ['foo2.html'] 341 This means that current result has 'foo1.html' but it is NOT in the 342 previous result. This also means the previous result has 'foo2.html' 343 but it is NOT in the current result. 344 receiver_email_address: receiver's email address. 345 test_group_name: string representing the test group name (e.g., 'media'). 346 appended_text_to_email: a text which is appended at the end of the status 347 email. 348 email_content: an email content string that will be shown on the dashboard. 349 rev_str: a revision string that contains revision information that is sent 350 out in the status email. It is obtained by calling 351 |GetRevisionString()|. 352 email_only_change_mode: send email only when there is a change if this is 353 True. Otherwise, always send email after each run. 354 """ 355 if rev_str: 356 email_content += '<br><b>Revision Information:</b>' 357 email_content += rev_str 358 localtime = time.asctime(time.localtime(time.time())) 359 change_str = '' 360 if email_only_change_mode: 361 change_str = 'Status Change ' 362 subject = 'Layout Test Analyzer Result %s(%s): %s' % (change_str, 363 test_group_name, 364 localtime) 365 SendEmail('no-reply (at] chromium.org', [receiver_email_address], 366 subject, email_content + appended_text_to_email) 367 368 369 def GetRevisionString(prev_time, current_time, diff_map): 370 """Get a string for revision information during the specified time period. 371 372 Args: 373 prev_time: the previous time as a floating point number expressed 374 in seconds since the epoch, in UTC. 375 current_time: the current time as a floating point number expressed 376 in seconds since the epoch, in UTC. It is typically obtained by 377 time.time() function. 378 diff_map: a map that has 'whole', 'skip' and 'nonskip' as keys. 379 Please refer to |diff_map| in |SendStatusEmail()|. 380 381 Returns: 382 a tuple of strings: 383 1) full string containing links, author, date, and line for each 384 change in the test expectation file. 385 2) shorter string containing only links to the change. Used for 386 trend graph annotations. 387 3) last revision number for the given test group. 388 4) last revision date for the given test group. 389 """ 390 if not diff_map: 391 return ('', '', '', '') 392 testname_map = {} 393 for test_group in ['skip', 'nonskip']: 394 for i in range(2): 395 for (k, _) in diff_map[test_group][i]: 396 testname_map[k] = True 397 rev_infos = TestExpectationsHistory.GetDiffBetweenTimes(prev_time, 398 current_time, 399 testname_map.keys()) 400 rev_str = '' 401 simple_rev_str = '' 402 rev = '' 403 rev_date = '' 404 if rev_infos: 405 # Get latest revision number and date. 406 rev = rev_infos[-1][1] 407 rev_date = rev_infos[-1][3] 408 for rev_info in rev_infos: 409 (old_rev, new_rev, author, date, _, target_lines) = rev_info 410 411 # test_expectations.txt was renamed to TestExpectations at r119317. 412 new_path = DEFAULT_TEST_EXPECTATION_PATH 413 if new_rev < 119317: 414 new_path = LEGACY_DEFAULT_TEST_EXPECTATION_PATH 415 old_path = DEFAULT_TEST_EXPECTATION_PATH 416 if old_rev < 119317: 417 old_path = LEGACY_DEFAULT_TEST_EXPECTATION_PATH 418 419 link = REVISION_LOG_URL % (new_path, old_rev, new_rev) 420 rev_str += '<ul><a href="%s">%s->%s</a>\n' % (link, old_rev, new_rev) 421 simple_rev_str = '<a href="%s">%s->%s</a>,' % (link, old_rev, new_rev) 422 rev_str += '<li>%s</li>\n' % author 423 rev_str += '<li>%s</li>\n<ul>' % date 424 for line in target_lines: 425 # Find *.html pattern (test name) and replace it with the link to 426 # flakiness dashboard. 427 test_name_pattern = r'(\S+.html)' 428 match = re.search(test_name_pattern, line) 429 if match: 430 test_name = match.group(1) 431 gpu_link = '' 432 if 'GPU' in line: 433 gpu_link = 'group=%40ToT%20GPU%20Mesa%20-%20chromium.org&' 434 dashboard_link = ('http://test-results.appspot.com/dashboards/' 435 'flakiness_dashboard.html#%stests=%s') % ( 436 gpu_link, test_name) 437 line = line.replace(test_name, '<a href="%s">%s</a>' % ( 438 dashboard_link, test_name)) 439 # Find bug text and replace it with the link to the bug. 440 bug = Bug(line) 441 if bug.bug_txt: 442 line = '<li>%s</li>\n' % line.replace(bug.bug_txt, str(bug)) 443 rev_str += line 444 rev_str += '</ul></ul>' 445 return (rev_str, simple_rev_str, rev, rev_date) 446 447 448 def SendEmail(sender_email_address, receivers_email_addresses, subject, 449 message): 450 """Send email using localhost's mail server. 451 452 Args: 453 sender_email_address: sender's email address. 454 receivers_email_addresses: receiver's email addresses. 455 subject: subject string. 456 message: email message. 457 """ 458 try: 459 html_top = """ 460 <html> 461 <head></head> 462 <body> 463 """ 464 html_bot = """ 465 </body> 466 </html> 467 """ 468 html = html_top + message + html_bot 469 msg = MIMEMultipart('alternative') 470 msg['Subject'] = subject 471 msg['From'] = sender_email_address 472 msg['To'] = receivers_email_addresses[0] 473 part1 = MIMEText(html, 'html') 474 smtp_obj = smtplib.SMTP('localhost') 475 msg.attach(part1) 476 smtp_obj.sendmail(sender_email_address, receivers_email_addresses, 477 msg.as_string()) 478 print 'Successfully sent email' 479 except smtplib.SMTPException, ex: 480 print 'Authentication failed:', ex 481 print 'Error: unable to send email' 482 except (socket.gaierror, socket.error, socket.herror), ex: 483 print ex 484 print 'Error: unable to send email' 485 486 487 def FindLatestTime(time_list): 488 """Find latest time from |time_list|. 489 490 The current status is compared to the status of the latest file in 491 |RESULT_DIR|. 492 493 Args: 494 time_list: a list of time string in the form of 'Year-Month-Day-Hour' 495 (e.g., 2011-10-23-23). Strings not in this format are ignored. 496 497 Returns: 498 a string representing latest time among the time_list or None if 499 |time_list| is empty or no valid date string in |time_list|. 500 """ 501 if not time_list: 502 return None 503 latest_date = None 504 for time_element in time_list: 505 try: 506 item_date = datetime.strptime(time_element, '%Y-%m-%d-%H') 507 if latest_date is None or latest_date < item_date: 508 latest_date = item_date 509 except ValueError: 510 # Do nothing. 511 pass 512 if latest_date: 513 return latest_date.strftime('%Y-%m-%d-%H') 514 else: 515 return None 516 517 518 def ReplaceLineInFile(file_path, search_exp, replace_line): 519 """Replace line which has |search_exp| with |replace_line| within a file. 520 521 Args: 522 file_path: the file that is being replaced. 523 search_exp: search expression to find a line to be replaced. 524 replace_line: the new line. 525 """ 526 for line in fileinput.input(file_path, inplace=1): 527 if search_exp in line: 528 line = replace_line 529 sys.stdout.write(line) 530 531 532 def FindLatestResult(result_dir): 533 """Find the latest result in |result_dir| and read and return them. 534 535 This is used for comparison of analyzer result between current analyzer 536 and most known latest result. 537 538 Args: 539 result_dir: the result directory. 540 541 Returns: 542 A tuple of filename (latest_time) and the latest analyzer result. 543 Returns None if there is no file or no file that matches the file 544 patterns used ('%Y-%m-%d-%H'). 545 """ 546 dir_list = os.listdir(result_dir) 547 file_name = FindLatestTime(dir_list) 548 if not file_name: 549 return None 550 file_path = os.path.join(result_dir, file_name) 551 return (file_name, AnalyzerResultMap.Load(file_path)) 552 553 554 def GetDiffBetweenMaps(map1, map2, lookIntoTestExpectationInfo=False): 555 """Get difference between maps. 556 557 Args: 558 map1: analyzer result map to be compared. 559 map2: analyzer result map to be compared. 560 lookIntoTestExpectationInfo: a boolean to indicate whether to compare 561 test expectation information in addition to just the test case names. 562 563 Returns: 564 a tuple of |name1_list| and |name2_list|. |Name1_list| contains all test 565 name and the test expectation information in |map1| but not in |map2|. 566 |Name2_list| contains all test name and the test expectation 567 information in |map2| but not in |map1|. 568 """ 569 570 def GetDiffBetweenMapsHelper(map1, map2, lookIntoTestExpectationInfo): 571 """A helper function for GetDiffBetweenMaps. 572 573 Args: 574 map1: analyzer result map to be compared. 575 map2: analyzer result map to be compared. 576 lookIntoTestExpectationInfo: a boolean to indicate whether to compare 577 test expectation information in addition to just the test case names. 578 579 Returns: 580 a list of tuples (name, te_info) that are in |map1| but not in |map2|. 581 """ 582 name_list = [] 583 for (name, value1) in map1.iteritems(): 584 if name in map2: 585 if lookIntoTestExpectationInfo and 'te_info' in value1: 586 list1 = value1['te_info'] 587 list2 = map2[name]['te_info'] 588 te_diff = [item for item in list1 if not item in list2] 589 if te_diff: 590 name_list.append((name, te_diff)) 591 else: 592 name_list.append((name, value1)) 593 return name_list 594 595 return (GetDiffBetweenMapsHelper(map1, map2, lookIntoTestExpectationInfo), 596 GetDiffBetweenMapsHelper(map2, map1, lookIntoTestExpectationInfo)) 597