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.
5 """Helper functions for the layout test analyzer."""
7 from datetime
import datetime
8 from email
.mime
.multipart
import MIMEMultipart
9 from email
.mime
.text
import MIMEText
20 from test_expectations_history
import TestExpectationsHistory
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'
30 class AnalyzerResultMap
:
31 """A class to deal with joined result produed by the analyzer.
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
44 def __init__(self
, test_info_map
):
45 """Initialize the AnalyzerResultMap based on test_info_map.
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.
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|).
61 self
.result_map
['whole'] = {}
62 self
.result_map
['skip'] = {}
63 self
.result_map
['nonskip'] = {}
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']]):
73 if any([True for x
in value
['te_info'] if 'SKIP' in x
]):
74 self
.result_map
['skip'][k
] = value
76 self
.result_map
['nonskip'][k
] = value
79 def GetDiffString(diff_map_element
, type_str
):
80 """Get difference string out of diff map element.
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.
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
98 a string in HTML format (with colors) to show difference between two
101 if not diff_map_element
[0] and not diff_map_element
[1]:
104 diff
= len(diff_map_element
[0]) - len(diff_map_element
[1])
105 if diff
> 0 and type_str
!= 'whole':
113 whole_str
= 'No Change'
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']
123 for (name
, _
) in diff_map_element
[0]:
124 str1
+= '<font color="%s">%s,</font>' % (colors
[0], name
)
126 for (name
, _
) in diff_map_element
[1]:
127 str2
+= '<font color="%s">%s,</font>' % (colors
[1], name
)
134 # Remove the last occurrence of ','.
135 whole_str
= ''.join(whole_str
.rsplit(',', 1))
138 def GetPassingRate(self
):
142 layout test passing rate of this result in percent.
145 ValueEror when the number of tests in test group "whole" is equal
146 or less than that of "skip".
148 delta
= len(self
.result_map
['whole'].keys()) - (
149 len(self
.result_map
['skip'].keys()))
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
155 def ConvertToCSVText(self
, current_time
):
156 """Convert |self.result_map| into stats and issues text in CSV format.
158 Both are used as inputs for Google spreadsheet.
161 current_time: a string depicting a time in year-month-day-hour
162 format (e.g., 2011-11-08-16).
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)
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)
175 "BUGWK,71543,TIMEOUT PASS,media/media-element-play-after-eos.html,
176 BUGCR,97657,IMAGE CPU MAC TIMEOUT PASS,media/audio-repaint.html,"
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())])
183 for bug_txt
, test_info_list
in (
184 self
.GetListOfBugsForNonSkippedTests().iteritems()):
185 matches
= re
.match(r
'(BUG(CR|WK))(\d+)', bug_txt
)
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
+ ','
196 return stats
, issues_txt
198 def ConvertToString(self
, prev_time
, diff_map
, issue_detail_mode
):
199 """Convert this result to HTML display for email.
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
208 a analyzer result string in HTML format.
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
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') % (
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'
244 def CompareToOtherResultMap(self
, other_result_map
):
245 """Compare this result map with the other to see if there are any diff.
247 The comparison is done for layouttests which belong to 'whole', 'skip',
251 other_result_map: another result map to be compared against the result
252 map of the current object.
255 a map that has 'whole', 'skip' and 'nonskip' as keys.
256 Please refer to |diff_map| in |SendStatusEmail()|.
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
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
273 """Load the object from |file_path| using pickle library.
276 file_path: the string path to the file from which to read the result.
279 a AnalyzerResultMap object read from |file_path|.
281 file_object
= open(file_path
)
282 analyzer_result_map
= pickle
.load(file_object
)
284 return analyzer_result_map
286 def Save(self
, file_path
):
287 """Save the object to |file_path| using pickle library.
290 file_path: the string path to the file in which to store the result.
292 file_object
= open(file_path
, 'wb')
293 pickle
.dump(self
, file_object
)
296 def GetListOfBugsForNonSkippedTests(self
):
297 """Get a list of bugs for non-skipped layout tests.
299 This is used for generating email content.
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.
307 for (name
, value
) in self
.result_map
['nonskip'].iteritems():
308 for te_info
in value
['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
:
317 bug_map
[bug
].append((name
, main_te_info
))
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.
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
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
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.
356 email_content
+= '<br><b>Revision Information:</b>'
357 email_content
+= rev_str
358 localtime
= time
.asctime(time
.localtime(time
.time()))
360 if email_only_change_mode
:
361 change_str
= 'Status Change '
362 subject
= 'Layout Test Analyzer Result %s(%s): %s' % (change_str
,
365 SendEmail('no-reply@chromium.org', [receiver_email_address
],
366 subject
, email_content
+ appended_text_to_email
)
369 def GetRevisionString(prev_time
, current_time
, diff_map
):
370 """Get a string for revision information during the specified time period.
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()|.
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.
391 return ('', '', '', '')
393 for test_group
in ['skip', 'nonskip']:
395 for (k
, _
) in diff_map
[test_group
][i
]:
396 testname_map
[k
] = True
397 rev_infos
= TestExpectationsHistory
.GetDiffBetweenTimes(prev_time
,
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
411 # test_expectations.txt was renamed to TestExpectations at r119317.
412 new_path
= DEFAULT_TEST_EXPECTATION_PATH
414 new_path
= LEGACY_DEFAULT_TEST_EXPECTATION_PATH
415 old_path
= DEFAULT_TEST_EXPECTATION_PATH
417 old_path
= LEGACY_DEFAULT_TEST_EXPECTATION_PATH
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
)
430 test_name
= match
.group(1)
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') % (
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.
442 line
= '<li>%s</li>\n' % line
.replace(bug
.bug_txt
, str(bug
))
444 rev_str
+= '</ul></ul>'
445 return (rev_str
, simple_rev_str
, rev
, rev_date
)
448 def SendEmail(sender_email_address
, receivers_email_addresses
, subject
,
450 """Send email using localhost's mail server.
453 sender_email_address: sender's email address.
454 receivers_email_addresses: receiver's email addresses.
455 subject: subject string.
456 message: email message.
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')
476 smtp_obj
.sendmail(sender_email_address
, receivers_email_addresses
,
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
:
484 print 'Error: unable to send email'
487 def FindLatestTime(time_list
):
488 """Find latest time from |time_list|.
490 The current status is compared to the status of the latest file in
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.
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|.
504 for time_element
in time_list
:
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
513 return latest_date
.strftime('%Y-%m-%d-%H')
518 def ReplaceLineInFile(file_path
, search_exp
, replace_line
):
519 """Replace line which has |search_exp| with |replace_line| within a file.
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.
526 for line
in fileinput
.input(file_path
, inplace
=1):
527 if search_exp
in line
:
529 sys
.stdout
.write(line
)
532 def FindLatestResult(result_dir
):
533 """Find the latest result in |result_dir| and read and return them.
535 This is used for comparison of analyzer result between current analyzer
536 and most known latest result.
539 result_dir: the result directory.
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').
546 dir_list
= os
.listdir(result_dir
)
547 file_name
= FindLatestTime(dir_list
)
550 file_path
= os
.path
.join(result_dir
, file_name
)
551 return (file_name
, AnalyzerResultMap
.Load(file_path
))
554 def GetDiffBetweenMaps(map1
, map2
, lookIntoTestExpectationInfo
=False):
555 """Get difference between maps.
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.
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|.
570 def GetDiffBetweenMapsHelper(map1
, map2
, lookIntoTestExpectationInfo
):
571 """A helper function for GetDiffBetweenMaps.
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.
580 a list of tuples (name, te_info) that are in |map1| but not in |map2|.
583 for (name
, value1
) in map1
.iteritems():
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
]
590 name_list
.append((name
, te_diff
))
592 name_list
.append((name
, value1
))
595 return (GetDiffBetweenMapsHelper(map1
, map2
, lookIntoTestExpectationInfo
),
596 GetDiffBetweenMapsHelper(map2
, map1
, lookIntoTestExpectationInfo
))