Home | History | Annotate | Download | only in layout_tests
      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