- add sources.
[platform/framework/web/crosswalk.git] / src / media / tools / layout_tests / layouttest_analyzer_helpers.py
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@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))