| # Copyright (c) 2012 The Chromium Authors. All rights reserved. |
| # Use of this source code is governed by a BSD-style license that can be |
| # found in the LICENSE file. |
| |
| """Helper functions for the layout test analyzer.""" |
| |
| from datetime import datetime |
| from email.mime.multipart import MIMEMultipart |
| from email.mime.text import MIMEText |
| import fileinput |
| import os |
| import pickle |
| import re |
| import smtplib |
| import socket |
| import sys |
| import time |
| |
| from bug import Bug |
| from test_expectations_history import TestExpectationsHistory |
| |
| DEFAULT_TEST_EXPECTATION_PATH = ('trunk/LayoutTests/TestExpectations') |
| LEGACY_DEFAULT_TEST_EXPECTATION_PATH = ( |
| 'trunk/LayoutTests/platform/chromium/test_expectations.txt') |
| REVISION_LOG_URL = ('http://build.chromium.org/f/chromium/perf/dashboard/ui/' |
| 'changelog_blink.html?url=/trunk/LayoutTests/%s&range=%d:%d') |
| DEFAULT_REVISION_VIEW_URL = 'http://src.chromium.org/viewvc/blink?revision=%s' |
| |
| |
| class AnalyzerResultMap: |
| """A class to deal with joined result produed by the analyzer. |
| |
| The join is done between layouttests and the test_expectations object |
| (based on the test expectation file). The instance variable |result_map| |
| contains the following keys: 'whole','skip','nonskip'. The value of 'whole' |
| contains information about all layouttests. The value of 'skip' contains |
| information about skipped layouttests where it has 'SKIP' in its entry in |
| the test expectation file. The value of 'nonskip' contains all information |
| about non skipped layout tests, which are in the test expectation file but |
| not skipped. The information is exactly same as the one parsed by the |
| analyzer. |
| """ |
| |
| def __init__(self, test_info_map): |
| """Initialize the AnalyzerResultMap based on test_info_map. |
| |
| Test_info_map contains all layouttest information. The job here is to |
| classify them as 'whole', 'skip' or 'nonskip' based on that information. |
| |
| Args: |
| test_info_map: the result map of |layouttests.JoinWithTestExpectation|. |
| The key of the map is test name such as 'media/media-foo.html'. |
| The value of the map is a map that contains the following keys: |
| 'desc'(description), 'te_info' (test expectation information), |
| which is a list of test expectation information map. The key of the |
| test expectation information map is test expectation keywords such |
| as "SKIP" and other keywords (for full list of keywords, please |
| refer to |test_expectations.ALL_TE_KEYWORDS|). |
| """ |
| self.result_map = {} |
| self.result_map['whole'] = {} |
| self.result_map['skip'] = {} |
| self.result_map['nonskip'] = {} |
| if test_info_map: |
| for (k, value) in test_info_map.iteritems(): |
| self.result_map['whole'][k] = value |
| if 'te_info' in value: |
| # Don't count SLOW PASS, WONTFIX, or ANDROID tests as failures. |
| if any([True for x in value['te_info'] if set(x.keys()) == |
| set(['SLOW', 'PASS', 'Bugs', 'Comments', 'Platforms']) or |
| 'WONTFIX' in x or x['Platforms'] == ['ANDROID']]): |
| continue |
| if any([True for x in value['te_info'] if 'SKIP' in x]): |
| self.result_map['skip'][k] = value |
| else: |
| self.result_map['nonskip'][k] = value |
| |
| @staticmethod |
| def GetDiffString(diff_map_element, type_str): |
| """Get difference string out of diff map element. |
| |
| The difference string shows difference between two analyzer results |
| (for example, a result for now and a result for sometime in the past) |
| in HTML format (with colors). This is used for generating email messages. |
| |
| Args: |
| diff_map_element: An element of the compared map generated by |
| |CompareResultMaps()|. The element has two lists of test cases. One |
| is for test names that are in the current result but NOT in the |
| previous result. The other is for test names that are in the previous |
| results but NOT in the current result. Please refer to comments in |
| |CompareResultMaps()| for details. |
| type_str: a string indicating the test group to which |diff_map_element| |
| belongs; used for color determination. Must be 'whole', 'skip', or |
| 'nonskip'. |
| |
| Returns: |
| a string in HTML format (with colors) to show difference between two |
| analyzer results. |
| """ |
| if not diff_map_element[0] and not diff_map_element[1]: |
| return 'No Change' |
| color = '' |
| diff = len(diff_map_element[0]) - len(diff_map_element[1]) |
| if diff > 0 and type_str != 'whole': |
| color = 'red' |
| else: |
| color = 'green' |
| diff_sign = '' |
| if diff > 0: |
| diff_sign = '+' |
| if not diff: |
| whole_str = 'No Change' |
| else: |
| whole_str = '<font color="%s">%s%d</font>' % (color, diff_sign, diff) |
| colors = ['red', 'green'] |
| if type_str == 'whole': |
| # Bug 107773 - when we increase the number of tests, |
| # the name of the tests are in red, it should be green |
| # since it is good thing. |
| colors = ['green', 'red'] |
| str1 = '' |
| for (name, _) in diff_map_element[0]: |
| str1 += '<font color="%s">%s,</font>' % (colors[0], name) |
| str2 = '' |
| for (name, _) in diff_map_element[1]: |
| str2 += '<font color="%s">%s,</font>' % (colors[1], name) |
| if str1 or str2: |
| whole_str += ':' |
| if str1: |
| whole_str += str1 |
| if str2: |
| whole_str += str2 |
| # Remove the last occurrence of ','. |
| whole_str = ''.join(whole_str.rsplit(',', 1)) |
| return whole_str |
| |
| def GetPassingRate(self): |
| """Get passing rate. |
| |
| Returns: |
| layout test passing rate of this result in percent. |
| |
| Raises: |
| ValueEror when the number of tests in test group "whole" is equal |
| or less than that of "skip". |
| """ |
| delta = len(self.result_map['whole'].keys()) - ( |
| len(self.result_map['skip'].keys())) |
| if delta <= 0: |
| raise ValueError('The number of tests in test group "whole" is equal or ' |
| 'less than that of "skip"') |
| return 100 - len(self.result_map['nonskip'].keys()) * 100.0 / delta |
| |
| def ConvertToCSVText(self, current_time): |
| """Convert |self.result_map| into stats and issues text in CSV format. |
| |
| Both are used as inputs for Google spreadsheet. |
| |
| Args: |
| current_time: a string depicting a time in year-month-day-hour |
| format (e.g., 2011-11-08-16). |
| |
| Returns: |
| a tuple of stats and issues_txt |
| stats: analyzer result in CSV format that shows: |
| (current_time, the number of tests, the number of skipped tests, |
| the number of failing tests, passing rate) |
| For example, |
| "2011-11-10-15,204,22,12,94" |
| issues_txt: issues listed in CSV format that shows: |
| (BUGWK or BUGCR, bug number, the test expectation entry, |
| the name of the test) |
| For example, |
| "BUGWK,71543,TIMEOUT PASS,media/media-element-play-after-eos.html, |
| BUGCR,97657,IMAGE CPU MAC TIMEOUT PASS,media/audio-repaint.html," |
| """ |
| stats = ','.join([current_time, str(len(self.result_map['whole'].keys())), |
| str(len(self.result_map['skip'].keys())), |
| str(len(self.result_map['nonskip'].keys())), |
| str(self.GetPassingRate())]) |
| issues_txt = '' |
| for bug_txt, test_info_list in ( |
| self.GetListOfBugsForNonSkippedTests().iteritems()): |
| matches = re.match(r'(BUG(CR|WK))(\d+)', bug_txt) |
| bug_suffix = '' |
| bug_no = '' |
| if matches: |
| bug_suffix = matches.group(1) |
| bug_no = matches.group(3) |
| issues_txt += bug_suffix + ',' + bug_no + ',' |
| for test_info in test_info_list: |
| test_name, te_info = test_info |
| issues_txt += ' '.join(te_info.keys()) + ',' + test_name + ',' |
| issues_txt += '\n' |
| return stats, issues_txt |
| |
| def ConvertToString(self, prev_time, diff_map, issue_detail_mode): |
| """Convert this result to HTML display for email. |
| |
| Args: |
| prev_time: the previous time string that are compared against. |
| diff_map: the compared map generated by |CompareResultMaps()|. |
| issue_detail_mode: includes the issue details in the output string if |
| this is True. |
| |
| Returns: |
| a analyzer result string in HTML format. |
| """ |
| return_str = '' |
| if diff_map: |
| return_str += ( |
| '<b>Statistics (Diff Compared to %s):</b><ul>' |
| '<li>The number of tests: %d (%s)</li>' |
| '<li>The number of failing skipped tests: %d (%s)</li>' |
| '<li>The number of failing non-skipped tests: %d (%s)</li>' |
| '<li>Passing rate: %.2f %%</li></ul>') % ( |
| prev_time, len(self.result_map['whole'].keys()), |
| AnalyzerResultMap.GetDiffString(diff_map['whole'], 'whole'), |
| len(self.result_map['skip'].keys()), |
| AnalyzerResultMap.GetDiffString(diff_map['skip'], 'skip'), |
| len(self.result_map['nonskip'].keys()), |
| AnalyzerResultMap.GetDiffString(diff_map['nonskip'], 'nonskip'), |
| self.GetPassingRate()) |
| if issue_detail_mode: |
| return_str += '<b>Current issues about failing non-skipped tests:</b>' |
| for (bug_txt, test_info_list) in ( |
| self.GetListOfBugsForNonSkippedTests().iteritems()): |
| return_str += '<ul>%s' % Bug(bug_txt) |
| for test_info in test_info_list: |
| (test_name, te_info) = test_info |
| gpu_link = '' |
| if 'GPU' in te_info: |
| gpu_link = 'group=%40ToT%20GPU%20Mesa%20-%20chromium.org&' |
| dashboard_link = ('http://test-results.appspot.com/dashboards/' |
| 'flakiness_dashboard.html#%stests=%s') % ( |
| gpu_link, test_name) |
| return_str += '<li><a href="%s">%s</a> (%s) </li>' % ( |
| dashboard_link, test_name, ' '.join( |
| [key for key in te_info.keys() if key != 'Platforms'])) |
| return_str += '</ul>\n' |
| return return_str |
| |
| def CompareToOtherResultMap(self, other_result_map): |
| """Compare this result map with the other to see if there are any diff. |
| |
| The comparison is done for layouttests which belong to 'whole', 'skip', |
| or 'nonskip'. |
| |
| Args: |
| other_result_map: another result map to be compared against the result |
| map of the current object. |
| |
| Returns: |
| a map that has 'whole', 'skip' and 'nonskip' as keys. |
| Please refer to |diff_map| in |SendStatusEmail()|. |
| """ |
| comp_result_map = {} |
| for name in ['whole', 'skip', 'nonskip']: |
| if name == 'nonskip': |
| # Look into expectation to get diff only for non-skipped tests. |
| lookIntoTestExpectationInfo = True |
| else: |
| # Otherwise, only test names are compared to get diff. |
| lookIntoTestExpectationInfo = False |
| comp_result_map[name] = GetDiffBetweenMaps( |
| self.result_map[name], other_result_map.result_map[name], |
| lookIntoTestExpectationInfo) |
| return comp_result_map |
| |
| @staticmethod |
| def Load(file_path): |
| """Load the object from |file_path| using pickle library. |
| |
| Args: |
| file_path: the string path to the file from which to read the result. |
| |
| Returns: |
| a AnalyzerResultMap object read from |file_path|. |
| """ |
| file_object = open(file_path) |
| analyzer_result_map = pickle.load(file_object) |
| file_object.close() |
| return analyzer_result_map |
| |
| def Save(self, file_path): |
| """Save the object to |file_path| using pickle library. |
| |
| Args: |
| file_path: the string path to the file in which to store the result. |
| """ |
| file_object = open(file_path, 'wb') |
| pickle.dump(self, file_object) |
| file_object.close() |
| |
| def GetListOfBugsForNonSkippedTests(self): |
| """Get a list of bugs for non-skipped layout tests. |
| |
| This is used for generating email content. |
| |
| Returns: |
| a mapping from bug modifier text (e.g., BUGCR1111) to a test name and |
| main test information string which excludes comments and bugs. |
| This is used for grouping test names by bug. |
| """ |
| bug_map = {} |
| for (name, value) in self.result_map['nonskip'].iteritems(): |
| for te_info in value['te_info']: |
| main_te_info = {} |
| for k in te_info.keys(): |
| if k != 'Comments' and k != 'Bugs': |
| main_te_info[k] = True |
| if 'Bugs' in te_info: |
| for bug in te_info['Bugs']: |
| if bug not in bug_map: |
| bug_map[bug] = [] |
| bug_map[bug].append((name, main_te_info)) |
| return bug_map |
| |
| |
| def SendStatusEmail(prev_time, analyzer_result_map, diff_map, |
| receiver_email_address, test_group_name, |
| appended_text_to_email, email_content, rev_str, |
| email_only_change_mode): |
| """Send status email. |
| |
| Args: |
| prev_time: the date string such as '2011-10-09-11'. This format has been |
| used in this analyzer. |
| analyzer_result_map: current analyzer result. |
| diff_map: a map that has 'whole', 'skip' and 'nonskip' as keys. |
| The values of the map are the result of |GetDiffBetweenMaps()|. |
| The element has two lists of test cases. One (with index 0) is for |
| test names that are in the current result but NOT in the previous |
| result. The other (with index 1) is for test names that are in the |
| previous results but NOT in the current result. |
| For example (test expectation information is omitted for |
| simplicity), |
| comp_result_map['whole'][0] = ['foo1.html'] |
| comp_result_map['whole'][1] = ['foo2.html'] |
| This means that current result has 'foo1.html' but it is NOT in the |
| previous result. This also means the previous result has 'foo2.html' |
| but it is NOT in the current result. |
| receiver_email_address: receiver's email address. |
| test_group_name: string representing the test group name (e.g., 'media'). |
| appended_text_to_email: a text which is appended at the end of the status |
| email. |
| email_content: an email content string that will be shown on the dashboard. |
| rev_str: a revision string that contains revision information that is sent |
| out in the status email. It is obtained by calling |
| |GetRevisionString()|. |
| email_only_change_mode: send email only when there is a change if this is |
| True. Otherwise, always send email after each run. |
| """ |
| if rev_str: |
| email_content += '<br><b>Revision Information:</b>' |
| email_content += rev_str |
| localtime = time.asctime(time.localtime(time.time())) |
| change_str = '' |
| if email_only_change_mode: |
| change_str = 'Status Change ' |
| subject = 'Layout Test Analyzer Result %s(%s): %s' % (change_str, |
| test_group_name, |
| localtime) |
| SendEmail('no-reply@chromium.org', [receiver_email_address], |
| subject, email_content + appended_text_to_email) |
| |
| |
| def GetRevisionString(prev_time, current_time, diff_map): |
| """Get a string for revision information during the specified time period. |
| |
| Args: |
| prev_time: the previous time as a floating point number expressed |
| in seconds since the epoch, in UTC. |
| current_time: the current time as a floating point number expressed |
| in seconds since the epoch, in UTC. It is typically obtained by |
| time.time() function. |
| diff_map: a map that has 'whole', 'skip' and 'nonskip' as keys. |
| Please refer to |diff_map| in |SendStatusEmail()|. |
| |
| Returns: |
| a tuple of strings: |
| 1) full string containing links, author, date, and line for each |
| change in the test expectation file. |
| 2) shorter string containing only links to the change. Used for |
| trend graph annotations. |
| 3) last revision number for the given test group. |
| 4) last revision date for the given test group. |
| """ |
| if not diff_map: |
| return ('', '', '', '') |
| testname_map = {} |
| for test_group in ['skip', 'nonskip']: |
| for i in range(2): |
| for (k, _) in diff_map[test_group][i]: |
| testname_map[k] = True |
| rev_infos = TestExpectationsHistory.GetDiffBetweenTimes(prev_time, |
| current_time, |
| testname_map.keys()) |
| rev_str = '' |
| simple_rev_str = '' |
| rev = '' |
| rev_date = '' |
| if rev_infos: |
| # Get latest revision number and date. |
| rev = rev_infos[-1][1] |
| rev_date = rev_infos[-1][3] |
| for rev_info in rev_infos: |
| (old_rev, new_rev, author, date, _, target_lines) = rev_info |
| |
| # test_expectations.txt was renamed to TestExpectations at r119317. |
| new_path = DEFAULT_TEST_EXPECTATION_PATH |
| if new_rev < 119317: |
| new_path = LEGACY_DEFAULT_TEST_EXPECTATION_PATH |
| old_path = DEFAULT_TEST_EXPECTATION_PATH |
| if old_rev < 119317: |
| old_path = LEGACY_DEFAULT_TEST_EXPECTATION_PATH |
| |
| link = REVISION_LOG_URL % (new_path, old_rev, new_rev) |
| rev_str += '<ul><a href="%s">%s->%s</a>\n' % (link, old_rev, new_rev) |
| simple_rev_str = '<a href="%s">%s->%s</a>,' % (link, old_rev, new_rev) |
| rev_str += '<li>%s</li>\n' % author |
| rev_str += '<li>%s</li>\n<ul>' % date |
| for line in target_lines: |
| # Find *.html pattern (test name) and replace it with the link to |
| # flakiness dashboard. |
| test_name_pattern = r'(\S+.html)' |
| match = re.search(test_name_pattern, line) |
| if match: |
| test_name = match.group(1) |
| gpu_link = '' |
| if 'GPU' in line: |
| gpu_link = 'group=%40ToT%20GPU%20Mesa%20-%20chromium.org&' |
| dashboard_link = ('http://test-results.appspot.com/dashboards/' |
| 'flakiness_dashboard.html#%stests=%s') % ( |
| gpu_link, test_name) |
| line = line.replace(test_name, '<a href="%s">%s</a>' % ( |
| dashboard_link, test_name)) |
| # Find bug text and replace it with the link to the bug. |
| bug = Bug(line) |
| if bug.bug_txt: |
| line = '<li>%s</li>\n' % line.replace(bug.bug_txt, str(bug)) |
| rev_str += line |
| rev_str += '</ul></ul>' |
| return (rev_str, simple_rev_str, rev, rev_date) |
| |
| |
| def SendEmail(sender_email_address, receivers_email_addresses, subject, |
| message): |
| """Send email using localhost's mail server. |
| |
| Args: |
| sender_email_address: sender's email address. |
| receivers_email_addresses: receiver's email addresses. |
| subject: subject string. |
| message: email message. |
| """ |
| try: |
| html_top = """ |
| <html> |
| <head></head> |
| <body> |
| """ |
| html_bot = """ |
| </body> |
| </html> |
| """ |
| html = html_top + message + html_bot |
| msg = MIMEMultipart('alternative') |
| msg['Subject'] = subject |
| msg['From'] = sender_email_address |
| msg['To'] = receivers_email_addresses[0] |
| part1 = MIMEText(html, 'html') |
| smtp_obj = smtplib.SMTP('localhost') |
| msg.attach(part1) |
| smtp_obj.sendmail(sender_email_address, receivers_email_addresses, |
| msg.as_string()) |
| print 'Successfully sent email' |
| except smtplib.SMTPException, ex: |
| print 'Authentication failed:', ex |
| print 'Error: unable to send email' |
| except (socket.gaierror, socket.error, socket.herror), ex: |
| print ex |
| print 'Error: unable to send email' |
| |
| |
| def FindLatestTime(time_list): |
| """Find latest time from |time_list|. |
| |
| The current status is compared to the status of the latest file in |
| |RESULT_DIR|. |
| |
| Args: |
| time_list: a list of time string in the form of 'Year-Month-Day-Hour' |
| (e.g., 2011-10-23-23). Strings not in this format are ignored. |
| |
| Returns: |
| a string representing latest time among the time_list or None if |
| |time_list| is empty or no valid date string in |time_list|. |
| """ |
| if not time_list: |
| return None |
| latest_date = None |
| for time_element in time_list: |
| try: |
| item_date = datetime.strptime(time_element, '%Y-%m-%d-%H') |
| if latest_date is None or latest_date < item_date: |
| latest_date = item_date |
| except ValueError: |
| # Do nothing. |
| pass |
| if latest_date: |
| return latest_date.strftime('%Y-%m-%d-%H') |
| else: |
| return None |
| |
| |
| def ReplaceLineInFile(file_path, search_exp, replace_line): |
| """Replace line which has |search_exp| with |replace_line| within a file. |
| |
| Args: |
| file_path: the file that is being replaced. |
| search_exp: search expression to find a line to be replaced. |
| replace_line: the new line. |
| """ |
| for line in fileinput.input(file_path, inplace=1): |
| if search_exp in line: |
| line = replace_line |
| sys.stdout.write(line) |
| |
| |
| def FindLatestResult(result_dir): |
| """Find the latest result in |result_dir| and read and return them. |
| |
| This is used for comparison of analyzer result between current analyzer |
| and most known latest result. |
| |
| Args: |
| result_dir: the result directory. |
| |
| Returns: |
| A tuple of filename (latest_time) and the latest analyzer result. |
| Returns None if there is no file or no file that matches the file |
| patterns used ('%Y-%m-%d-%H'). |
| """ |
| dir_list = os.listdir(result_dir) |
| file_name = FindLatestTime(dir_list) |
| if not file_name: |
| return None |
| file_path = os.path.join(result_dir, file_name) |
| return (file_name, AnalyzerResultMap.Load(file_path)) |
| |
| |
| def GetDiffBetweenMaps(map1, map2, lookIntoTestExpectationInfo=False): |
| """Get difference between maps. |
| |
| Args: |
| map1: analyzer result map to be compared. |
| map2: analyzer result map to be compared. |
| lookIntoTestExpectationInfo: a boolean to indicate whether to compare |
| test expectation information in addition to just the test case names. |
| |
| Returns: |
| a tuple of |name1_list| and |name2_list|. |Name1_list| contains all test |
| name and the test expectation information in |map1| but not in |map2|. |
| |Name2_list| contains all test name and the test expectation |
| information in |map2| but not in |map1|. |
| """ |
| |
| def GetDiffBetweenMapsHelper(map1, map2, lookIntoTestExpectationInfo): |
| """A helper function for GetDiffBetweenMaps. |
| |
| Args: |
| map1: analyzer result map to be compared. |
| map2: analyzer result map to be compared. |
| lookIntoTestExpectationInfo: a boolean to indicate whether to compare |
| test expectation information in addition to just the test case names. |
| |
| Returns: |
| a list of tuples (name, te_info) that are in |map1| but not in |map2|. |
| """ |
| name_list = [] |
| for (name, value1) in map1.iteritems(): |
| if name in map2: |
| if lookIntoTestExpectationInfo and 'te_info' in value1: |
| list1 = value1['te_info'] |
| list2 = map2[name]['te_info'] |
| te_diff = [item for item in list1 if not item in list2] |
| if te_diff: |
| name_list.append((name, te_diff)) |
| else: |
| name_list.append((name, value1)) |
| return name_list |
| |
| return (GetDiffBetweenMapsHelper(map1, map2, lookIntoTestExpectationInfo), |
| GetDiffBetweenMapsHelper(map2, map1, lookIntoTestExpectationInfo)) |