1#!/usr/bin/env python
2# Copyright (c) 2012 The Chromium Authors. All rights reserved.
3# Use of this source code is governed by a BSD-style license that can be
4# found in the LICENSE file.
5
6"""Main functions for the Layout Test Analyzer module."""
7
8from datetime import datetime
9import optparse
10import os
11import sys
12import time
13
14import layouttest_analyzer_helpers
15from layouttest_analyzer_helpers import DEFAULT_REVISION_VIEW_URL
16import layouttests
17from layouttests import DEFAULT_LAYOUTTEST_SVN_VIEW_LOCATION
18
19from test_expectations import TestExpectations
20from trend_graph import TrendGraph
21
22# Predefined result directory.
23DEFAULT_RESULT_DIR = 'result'
24# TODO(shadi): Remove graph functions as they are not used any more.
25DEFAULT_GRAPH_FILE = os.path.join('graph', 'graph.html')
26# TODO(shadi): Check if these files are needed any more.
27DEFAULT_STATS_CSV_FILENAME = 'stats.csv'
28DEFAULT_ISSUES_CSV_FILENAME = 'issues.csv'
29# TODO(shadi): These are used only for |debug| mode. What is debug mode for?
30#              AFAIK, we don't run debug mode, should be safe to remove.
31# Predefined result files for debug.
32CUR_TIME_FOR_DEBUG = '2011-09-11-19'
33CURRENT_RESULT_FILE_FOR_DEBUG = os.path.join(DEFAULT_RESULT_DIR,
34                                             CUR_TIME_FOR_DEBUG)
35PREV_TIME_FOR_DEBUG = '2011-09-11-18'
36
37# Text to append at the end of every analyzer result email.
38DEFAULT_EMAIL_APPEND_TEXT = (
39    '<b><a href="https://groups.google.com/a/google.com/group/'
40    'layout-test-analyzer-result/topics">Email History</a></b><br>'
41  )
42
43
44def ParseOption():
45  """Parse command-line options using OptionParser.
46
47  Returns:
48      an object containing all command-line option information.
49  """
50  option_parser = optparse.OptionParser()
51
52  option_parser.add_option('-r', '--receiver-email-address',
53                           dest='receiver_email_address',
54                           help=('receiver\'s email address. '
55                                 'Result email is not sent if this is not '
56                                 'specified.'))
57  option_parser.add_option('-g', '--debug-mode', dest='debug',
58                           help=('Debug mode is used when you want to debug '
59                                 'the analyzer by using local file rather '
60                                 'than getting data from SVN. This shortens '
61                                 'the debugging time (off by default).'),
62                           action='store_true', default=False)
63  option_parser.add_option('-t', '--trend-graph-location',
64                           dest='trend_graph_location',
65                           help=('Location of the bug trend file; '
66                                 'file is expected to be in Google '
67                                 'Visualization API trend-line format '
68                                 '(defaults to %default).'),
69                           default=DEFAULT_GRAPH_FILE)
70  option_parser.add_option('-n', '--test-group-file-location',
71                           dest='test_group_file_location',
72                           help=('Location of the test group file; '
73                                 'file is expected to be in CSV format '
74                                 'and lists all test name patterns. '
75                                 'When this option is not specified, '
76                                 'the value of --test-group-name is used '
77                                 'for a test name pattern.'),
78                           default=None)
79  option_parser.add_option('-x', '--test-group-name',
80                           dest='test_group_name',
81                           help=('A name of test group. Either '
82                                 '--test_group_file_location or this option '
83                                 'needs to be specified.'))
84  option_parser.add_option('-d', '--result-directory-location',
85                           dest='result_directory_location',
86                           help=('Name of result directory location '
87                                 '(default to %default).'),
88                           default=DEFAULT_RESULT_DIR)
89  option_parser.add_option('-b', '--email-appended-text-file-location',
90                           dest='email_appended_text_file_location',
91                           help=('File location of the email appended text. '
92                                 'The text is appended in the status email. '
93                                 '(default to %default and no text is '
94                                 'appended in that case).'),
95                           default=None)
96  option_parser.add_option('-c', '--email-only-change-mode',
97                           dest='email_only_change_mode',
98                           help=('With this mode, email is sent out '
99                                 'only when there is a change in the '
100                                 'analyzer result compared to the previous '
101                                 'result (off by default)'),
102                           action='store_true', default=False)
103  option_parser.add_option('-q', '--dashboard-file-location',
104                           dest='dashboard_file_location',
105                           help=('Location of dashboard file. The results are '
106                                 'not reported to the dashboard if this '
107                                 'option is not specified.'))
108  option_parser.add_option('-z', '--issue-detail-mode',
109                           dest='issue_detail_mode',
110                           help=('With this mode, email includes issue details '
111                                 '(links to the flakiness dashboard)'
112                                 ' (off by default)'),
113                           action='store_true', default=False)
114  return option_parser.parse_args()[0]
115
116
117def GetCurrentAndPreviousResults(debug, test_group_file_location,
118                                 test_group_name, result_directory_location):
119  """Get current and the latest previous analyzer results.
120
121  In debug mode, they are read from predefined files. In non-debug mode,
122  current analyzer results are dynamically obtained from Blink SVN and
123  the latest previous result is read from the corresponding file.
124
125  Args:
126    debug: please refer to |options|.
127    test_group_file_location: please refer to |options|.
128    test_group_name: please refer to |options|.
129    result_directory_location: please refer to |options|.
130
131  Returns:
132    a tuple of the following:
133       prev_time: the previous time string that is compared against.
134       prev_analyzer_result_map: previous analyzer result map. Please refer to
135          layouttest_analyzer_helpers.AnalyzerResultMap.
136       analyzer_result_map: current analyzer result map. Please refer to
137          layouttest_analyzer_helpers.AnalyzerResultMap.
138  """
139  if not debug:
140    if not test_group_file_location and not test_group_name:
141      print ('Either --test-group-name or --test_group_file_location must be '
142             'specified. Exiting this program.')
143      sys.exit()
144    filter_names = []
145    if test_group_file_location and os.path.exists(test_group_file_location):
146      filter_names = layouttests.LayoutTests.GetLayoutTestNamesFromCSV(
147          test_group_file_location)
148      parent_location_list = (
149          layouttests.LayoutTests.GetParentDirectoryList(filter_names))
150      recursion = True
151    else:
152      # When test group CSV file is not specified, test group name
153      # (e.g., 'media') is used for getting layout tests.
154      # The tests are in
155      #     http://src.chromium.org/blink/trunk/LayoutTests/media
156      # Filtering is not set so all HTML files are considered as valid tests.
157      # Also, we look for the tests recursively.
158      if not test_group_file_location or (
159          not os.path.exists(test_group_file_location)):
160        print ('Warning: CSV file (%s) does not exist. So it is ignored and '
161               '%s is used for obtaining test names') % (
162                   test_group_file_location, test_group_name)
163      if not test_group_name.endswith('/'):
164        test_group_name += '/'
165      parent_location_list = [test_group_name]
166      filter_names = None
167      recursion = True
168    layouttests_object = layouttests.LayoutTests(
169        parent_location_list=parent_location_list, recursion=recursion,
170        filter_names=filter_names)
171    analyzer_result_map = layouttest_analyzer_helpers.AnalyzerResultMap(
172        layouttests_object.JoinWithTestExpectation(TestExpectations()))
173    result = layouttest_analyzer_helpers.FindLatestResult(
174        result_directory_location)
175    if result:
176      (prev_time, prev_analyzer_result_map) = result
177    else:
178      prev_time = None
179      prev_analyzer_result_map = None
180  else:
181    analyzer_result_map = layouttest_analyzer_helpers.AnalyzerResultMap.Load(
182        CURRENT_RESULT_FILE_FOR_DEBUG)
183    prev_time = PREV_TIME_FOR_DEBUG
184    prev_analyzer_result_map = (
185        layouttest_analyzer_helpers.AnalyzerResultMap.Load(
186            os.path.join(DEFAULT_RESULT_DIR, prev_time)))
187  return (prev_time, prev_analyzer_result_map, analyzer_result_map)
188
189
190def SendEmail(prev_time, prev_analyzer_result_map, analyzer_result_map,
191              appended_text_to_email, email_only_change_mode, debug,
192              receiver_email_address, test_group_name, issue_detail_mode):
193  """Send result status email.
194
195  Args:
196    prev_time: the previous time string that is compared against.
197    prev_analyzer_result_map: previous analyzer result map. Please refer to
198        layouttest_analyzer_helpers.AnalyzerResultMap.
199    analyzer_result_map: current analyzer result map. Please refer to
200        layouttest_analyzer_helpers.AnalyzerResultMap.
201    appended_text_to_email: the text string to append to the status email.
202    email_only_change_mode: please refer to |options|.
203    debug: please refer to |options|.
204    receiver_email_address: please refer to |options|.
205    test_group_name: please refer to |options|.
206    issue_detail_mode: please refer to |options|.
207
208  Returns:
209    a tuple of the following:
210        result_change: a boolean indicating whether there is a change in the
211            result compared with the latest past result.
212        diff_map: please refer to
213            layouttest_analyzer_helpers.SendStatusEmail().
214        simple_rev_str: a simple version of revision string that is sent in
215            the email.
216        rev: the latest revision number for the given test group.
217        rev_date: the latest revision date for the given test group.
218        email_content:  email content string (without
219            |appended_text_to_email|) that will be shown on the dashboard.
220  """
221  rev = ''
222  rev_date = ''
223  email_content = ''
224  if prev_analyzer_result_map:
225    diff_map = analyzer_result_map.CompareToOtherResultMap(
226        prev_analyzer_result_map)
227    result_change = (any(diff_map['whole']) or any(diff_map['skip']) or
228                     any(diff_map['nonskip']))
229    # Email only when |email_only_change_mode| is False or there
230    # is a change in the result compared to the last result.
231    simple_rev_str = ''
232    if not email_only_change_mode or result_change:
233      prev_time_in_float = datetime.strptime(prev_time, '%Y-%m-%d-%H')
234      prev_time_in_float = time.mktime(prev_time_in_float.timetuple())
235      if debug:
236        cur_time_in_float = datetime.strptime(CUR_TIME_FOR_DEBUG,
237                                              '%Y-%m-%d-%H')
238        cur_time_in_float = time.mktime(cur_time_in_float.timetuple())
239      else:
240        cur_time_in_float = time.time()
241      (rev_str, simple_rev_str, rev, rev_date) = (
242          layouttest_analyzer_helpers.GetRevisionString(prev_time_in_float,
243                                                        cur_time_in_float,
244                                                        diff_map))
245      email_content = analyzer_result_map.ConvertToString(prev_time,
246                                                          diff_map,
247                                                          issue_detail_mode)
248      if receiver_email_address:
249        layouttest_analyzer_helpers.SendStatusEmail(
250            prev_time, analyzer_result_map, diff_map,
251            receiver_email_address, test_group_name,
252            appended_text_to_email, email_content, rev_str,
253            email_only_change_mode)
254    if simple_rev_str:
255      simple_rev_str = '\'' + simple_rev_str + '\''
256    else:
257      simple_rev_str = 'undefined'  # GViz uses undefined for NONE.
258  else:
259    # Initial result should be written to tread-graph if there are no previous
260    # results.
261    result_change = True
262    diff_map = None
263    simple_rev_str = 'undefined'
264    email_content = analyzer_result_map.ConvertToString(None, diff_map,
265                                                        issue_detail_mode)
266  return (result_change, diff_map, simple_rev_str, rev, rev_date,
267          email_content)
268
269
270def UpdateTrendGraph(start_time, analyzer_result_map, diff_map, simple_rev_str,
271                     trend_graph_location):
272  """Update trend graph in GViz.
273
274  Annotate the graph with revision information.
275
276  Args:
277    start_time: the script starting time as a float value.
278    analyzer_result_map: current analyzer result map. Please refer to
279        layouttest_analyzer_helpers.AnalyzerResultMap.
280    diff_map: a map that has 'whole', 'skip' and 'nonskip' as keys.
281        Please refer to |diff_map| in
282        |layouttest_analyzer_helpers.SendStatusEmail()|.
283    simple_rev_str: a simple version of revision string that is sent in
284        the email.
285    trend_graph_location: the location of the trend graph that needs to be
286        updated.
287
288  Returns:
289     a dictionary that maps result data category ('whole', 'skip', 'nonskip',
290         'passingrate') to information tuple (a dictionary that maps test name
291         to its description, annotation, simple_rev_string) of the given result
292         data category. These tuples are used for trend graph update.
293  """
294  # Trend graph update (if specified in the command-line argument) when
295  # there is change from the last result.
296  # Currently, there are two graphs (graph1 is for 'whole', 'skip',
297  # 'nonskip' and the graph2 is for 'passingrate'). Please refer to
298  # graph/graph.html.
299  # Sample JS annotation for graph1:
300  #   [new Date(2011,8,12,10,41,32),224,undefined,'',52,undefined,
301  #    undefined, 12, 'test1,','<a href="http://t</a>,',],
302  # This example lists 'whole' triple and 'skip' triple and
303  # 'nonskip' triple. Each triple is (the number of tests that belong to
304  # the test group, linked text, a link). The following code generates this
305  # automatically based on rev_string etc.
306  trend_graph = TrendGraph(trend_graph_location)
307  datetime_string = start_time.strftime('%Y,%m,%d,%H,%M,%S')
308  data_map = {}
309  passingrate_anno = ''
310  for test_group in ['whole', 'skip', 'nonskip']:
311    anno = 'undefined'
312    # Extract test description.
313    test_map = {}
314    for (test_name, value) in (
315        analyzer_result_map.result_map[test_group].iteritems()):
316      test_map[test_name] = value['desc']
317    test_str = ''
318    links = ''
319    if diff_map and diff_map[test_group]:
320      for i in [0, 1]:
321        for (name, _) in diff_map[test_group][i]:
322          test_str += name + ','
323          # This is link to test HTML in SVN.
324          links += ('<a href="%s%s">%s</a>' %
325                    (DEFAULT_LAYOUTTEST_SVN_VIEW_LOCATION, name, name))
326      if test_str:
327        anno = '\'' + test_str + '\''
328        # The annotation of passing rate is a union of all annotations.
329        passingrate_anno += anno
330    if links:
331      links = '\'' + links + '\''
332    else:
333      links = 'undefined'
334    if test_group is 'whole':
335      data_map[test_group] = (test_map, anno, links)
336    else:
337      data_map[test_group] = (test_map, anno, simple_rev_str)
338  if not passingrate_anno:
339    passingrate_anno = 'undefined'
340  data_map['passingrate'] = (
341      str(analyzer_result_map.GetPassingRate()), passingrate_anno,
342      simple_rev_str)
343  trend_graph.Update(datetime_string, data_map)
344  return data_map
345
346
347def UpdateDashboard(dashboard_file_location, test_group_name, data_map,
348                    layouttest_root_path, rev, rev_date, email,
349                    email_content):
350  """Update dashboard HTML file.
351
352  Args:
353    dashboard_file_location: the file location for the dashboard file.
354    test_group_name: please refer to |options|.
355    data_map: a dictionary that maps result data category ('whole', 'skip',
356        'nonskip', 'passingrate') to information tuple (a dictionary that maps
357        test name to its description, annotation, simple_rev_string) of the
358        given result data category. These tuples are used for trend graph
359        update.
360    layouttest_root_path: A location string where layout tests are stored.
361    rev: the latest revision number for the given test group.
362    rev_date: the latest revision date for the given test group.
363    email: email address of the owner for the given test group.
364    email_content:  email content string (without |appended_text_to_email|)
365        that will be shown on the dashboard.
366  """
367  # Generate a HTML file that contains all test names for each test group.
368  escaped_tg_name = test_group_name.replace('/', '_')
369  for tg in ['whole', 'skip', 'nonskip']:
370    file_name = os.path.join(
371        os.path.dirname(dashboard_file_location),
372        escaped_tg_name + '_' + tg + '.html')
373    file_object = open(file_name, 'wb')
374    file_object.write('<table border="1">')
375    sorted_testnames = data_map[tg][0].keys()
376    sorted_testnames.sort()
377    for testname in sorted_testnames:
378      file_object.write((
379          '<tr><td><a href="%s">%s</a></td><td><a href="%s">dashboard</a>'
380          '</td><td>%s</td></tr>') % (
381              layouttest_root_path + testname, testname,
382              ('http://test-results.appspot.com/dashboards/'
383               'flakiness_dashboard.html#tests=%s') % testname,
384              data_map[tg][0][testname]))
385    file_object.write('</table>')
386    file_object.close()
387  email_content_with_link = ''
388  if email_content:
389    file_name = os.path.join(os.path.dirname(dashboard_file_location),
390                             escaped_tg_name + '_email.html')
391    file_object = open(file_name, 'wb')
392    file_object.write(email_content)
393    file_object.close()
394    email_content_with_link = '<a href="%s_email.html">info</a>' % (
395        escaped_tg_name)
396  test_group_str = (
397      '<td><a href="%(test_group_path)s">%(test_group_name)s</a></td>'
398      '<td><a href="%(graph_path)s">graph</a></td>'
399      '<td><a href="%(all_tests_path)s">%(all_tests_count)d</a></td>'
400      '<td><a href="%(skip_tests_path)s">%(skip_tests_count)d</a></td>'
401      '<td><a href="%(nonskip_tests_path)s">%(nonskip_tests_count)d</a></td>'
402      '<td>%(fail_rate)d%%</td>'
403      '<td>%(passing_rate)d%%</td>'
404      '<td><a href="%(rev_url)s">%(rev)s</a></td>'
405      '<td>%(rev_date)s</td>'
406      '<td><a href="mailto:%(email)s">%(email)s</a></td>'
407      '<td>%(email_content)s</td>\n') % {
408          # Dashboard file and graph must be in the same directory
409          # to make the following link work.
410          'test_group_path': layouttest_root_path + '/' + test_group_name,
411          'test_group_name': test_group_name,
412          'graph_path': escaped_tg_name + '.html',
413          'all_tests_path': escaped_tg_name + '_whole.html',
414          'all_tests_count': len(data_map['whole'][0]),
415          'skip_tests_path': escaped_tg_name + '_skip.html',
416          'skip_tests_count': len(data_map['skip'][0]),
417          'nonskip_tests_path': escaped_tg_name + '_nonskip.html',
418          'nonskip_tests_count': len(data_map['nonskip'][0]),
419          'fail_rate': 100 - float(data_map['passingrate'][0]),
420          'passing_rate': float(data_map['passingrate'][0]),
421          'rev_url': DEFAULT_REVISION_VIEW_URL % rev,
422          'rev': rev,
423          'rev_date': rev_date,
424          'email': email,
425          'email_content': email_content_with_link
426      }
427  layouttest_analyzer_helpers.ReplaceLineInFile(
428      dashboard_file_location, '<td>' + test_group_name + '</td>',
429      test_group_str)
430
431
432def main():
433  """A main function for the analyzer."""
434  options = ParseOption()
435  start_time = datetime.now()
436
437  (prev_time, prev_analyzer_result_map, analyzer_result_map) = (
438      GetCurrentAndPreviousResults(options.debug,
439                                   options.test_group_file_location,
440                                   options.test_group_name,
441                                   options.result_directory_location))
442  (result_change, diff_map, simple_rev_str, rev, rev_date, email_content) = (
443      SendEmail(prev_time, prev_analyzer_result_map, analyzer_result_map,
444                DEFAULT_EMAIL_APPEND_TEXT,
445                options.email_only_change_mode, options.debug,
446                options.receiver_email_address, options.test_group_name,
447                options.issue_detail_mode))
448
449  # Create CSV texts and save them for bug spreadsheet.
450  (stats, issues_txt) = analyzer_result_map.ConvertToCSVText(
451      start_time.strftime('%Y-%m-%d-%H'))
452  file_object = open(os.path.join(options.result_directory_location,
453                                  DEFAULT_STATS_CSV_FILENAME), 'wb')
454  file_object.write(stats)
455  file_object.close()
456  file_object = open(os.path.join(options.result_directory_location,
457                                  DEFAULT_ISSUES_CSV_FILENAME), 'wb')
458  file_object.write(issues_txt)
459  file_object.close()
460
461  if not options.debug and (result_change or not prev_analyzer_result_map):
462    # Save the current result when result is changed or the script is
463    # executed for the first time.
464    date = start_time.strftime('%Y-%m-%d-%H')
465    file_path = os.path.join(options.result_directory_location, date)
466    analyzer_result_map.Save(file_path)
467  if result_change or not prev_analyzer_result_map:
468    data_map = UpdateTrendGraph(start_time, analyzer_result_map, diff_map,
469                                simple_rev_str, options.trend_graph_location)
470    # Report the result to dashboard.
471    if options.dashboard_file_location:
472      UpdateDashboard(options.dashboard_file_location, options.test_group_name,
473                      data_map, layouttests.DEFAULT_LAYOUTTEST_LOCATION, rev,
474                      rev_date, options.receiver_email_address,
475                      email_content)
476
477
478if '__main__' == __name__:
479  main()
480