• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1#!/usr/bin/python
2# Copyright (c) 2010 The Chromium OS 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
7"""Parses and displays the contents of one or more autoserv result directories.
8
9This script parses the contents of one or more autoserv results folders and
10generates test reports.
11"""
12
13import datetime
14import glob
15import logging
16import operator
17import optparse
18import os
19import re
20import sys
21
22import common
23try:
24    # Ensure the chromite site-package is installed.
25    from chromite.lib import terminal
26except ImportError:
27    import subprocess
28    build_externals_path = os.path.join(
29            os.path.dirname(os.path.dirname(os.path.realpath(__file__))),
30            'utils', 'build_externals.py')
31    subprocess.check_call([build_externals_path, 'chromiterepo'])
32    # Restart the script so python now finds the autotest site-packages.
33    sys.exit(os.execv(__file__, sys.argv))
34
35
36_STDOUT_IS_TTY = hasattr(sys.stdout, 'isatty') and sys.stdout.isatty()
37
38
39def Die(message_format, *args, **kwargs):
40    """Log a message and kill the current process.
41
42    @param message_format: string for logging.error.
43
44    """
45    logging.error(message_format, *args, **kwargs)
46    sys.exit(1)
47
48
49class CrashWaiver:
50    """Represents a crash that we want to ignore for now."""
51    def __init__(self, signals, deadline, url, person):
52        self.signals = signals
53        self.deadline = datetime.datetime.strptime(deadline, '%Y-%b-%d')
54        self.issue_url = url
55        self.suppressor = person
56
57# List of crashes which are okay to ignore. This list should almost always be
58# empty. If you add an entry, include the bug URL and your name, something like
59#     'crashy':CrashWaiver(
60#       ['sig 11'], '2011-Aug-18', 'http://crosbug/123456', 'developer'),
61
62_CRASH_WHITELIST = {
63}
64
65
66class ResultCollector(object):
67    """Collects status and performance data from an autoserv results dir."""
68
69    def __init__(self, collect_perf=True, collect_attr=False,
70                 collect_info=False, escape_error=False,
71                 whitelist_chrome_crashes=False):
72        """Initialize ResultsCollector class.
73
74        @param collect_perf: Should perf keyvals be collected?
75        @param collect_attr: Should attr keyvals be collected?
76        @param collect_info: Should info keyvals be collected?
77        @param escape_error: Escape error message text for tools.
78        @param whitelist_chrome_crashes: Treat Chrome crashes as non-fatal.
79
80        """
81        self._collect_perf = collect_perf
82        self._collect_attr = collect_attr
83        self._collect_info = collect_info
84        self._escape_error = escape_error
85        self._whitelist_chrome_crashes = whitelist_chrome_crashes
86
87    def _CollectPerf(self, testdir):
88        """Parses keyval file under testdir and return the perf keyval pairs.
89
90        @param testdir: autoserv test result directory path.
91
92        @return dict of perf keyval pairs.
93
94        """
95        if not self._collect_perf:
96            return {}
97        return self._CollectKeyval(testdir, 'perf')
98
99    def _CollectAttr(self, testdir):
100        """Parses keyval file under testdir and return the attr keyval pairs.
101
102        @param testdir: autoserv test result directory path.
103
104        @return dict of attr keyval pairs.
105
106        """
107        if not self._collect_attr:
108            return {}
109        return self._CollectKeyval(testdir, 'attr')
110
111    def _CollectKeyval(self, testdir, keyword):
112        """Parses keyval file under testdir.
113
114        If testdir contains a result folder, process the keyval file and return
115        a dictionary of perf keyval pairs.
116
117        @param testdir: The autoserv test result directory.
118        @param keyword: The keyword of keyval, either 'perf' or 'attr'.
119
120        @return If the perf option is disabled or the there's no keyval file
121                under testdir, returns an empty dictionary. Otherwise, returns
122                a dictionary of parsed keyvals. Duplicate keys are uniquified
123                by their instance number.
124
125        """
126        keyval = {}
127        keyval_file = os.path.join(testdir, 'results', 'keyval')
128        if not os.path.isfile(keyval_file):
129            return keyval
130
131        instances = {}
132
133        for line in open(keyval_file):
134            match = re.search(r'^(.+){%s}=(.+)$' % keyword, line)
135            if match:
136                key = match.group(1)
137                val = match.group(2)
138
139                # If the same key name was generated multiple times, uniquify
140                # all instances other than the first one by adding the instance
141                # count to the key name.
142                key_inst = key
143                instance = instances.get(key, 0)
144                if instance:
145                    key_inst = '%s{%d}' % (key, instance)
146                instances[key] = instance + 1
147
148                keyval[key_inst] = val
149
150        return keyval
151
152    def _CollectCrashes(self, status_raw):
153        """Parses status_raw file for crashes.
154
155        Saves crash details if crashes are discovered.  If a whitelist is
156        present, only records whitelisted crashes.
157
158        @param status_raw: The contents of the status.log or status file from
159                the test.
160
161        @return a list of crash entries to be reported.
162
163        """
164        crashes = []
165        regex = re.compile(
166                'Received crash notification for ([-\w]+).+ (sig \d+)')
167        chrome_regex = re.compile(r'^supplied_[cC]hrome|^chrome$')
168        for match in regex.finditer(status_raw):
169            w = _CRASH_WHITELIST.get(match.group(1))
170            if (self._whitelist_chrome_crashes and
171                    chrome_regex.match(match.group(1))):
172                print '@@@STEP_WARNINGS@@@'
173                print '%s crashed with %s' % (match.group(1), match.group(2))
174            elif (w is not None and match.group(2) in w.signals and
175                        w.deadline > datetime.datetime.now()):
176                print 'Ignoring crash in %s for waiver that expires %s' % (
177                        match.group(1), w.deadline.strftime('%Y-%b-%d'))
178            else:
179                crashes.append('%s %s' % match.groups())
180        return crashes
181
182    def _CollectInfo(self, testdir, custom_info):
183        """Parses *_info files under testdir/sysinfo/var/log.
184
185        If the sysinfo/var/log/*info files exist, save information that shows
186        hw, ec and bios version info.
187
188        This collection of extra info is disabled by default (this funtion is
189        a no-op).  It is enabled only if the --info command-line option is
190        explicitly supplied.  Normal job parsing does not supply this option.
191
192        @param testdir: The autoserv test result directory.
193        @param custom_info: Dictionary to collect detailed ec/bios info.
194
195        @return a dictionary of info that was discovered.
196
197        """
198        if not self._collect_info:
199            return {}
200        info = custom_info
201
202        sysinfo_dir = os.path.join(testdir, 'sysinfo', 'var', 'log')
203        for info_file, info_keys in {'ec_info.txt': ['fw_version'],
204                                     'bios_info.txt': ['fwid',
205                                                       'hwid']}.iteritems():
206            info_file_path = os.path.join(sysinfo_dir, info_file)
207            if not os.path.isfile(info_file_path):
208                continue
209            # Some example raw text that might be matched include:
210            #
211            # fw_version           | snow_v1.1.332-cf20b3e
212            # fwid = Google_Snow.2711.0.2012_08_06_1139 # Active firmware ID
213            # hwid = DAISY TEST A-A 9382                # Hardware ID
214            info_regex = re.compile(r'^(%s)\s*[|=]\s*(.*)' %
215                                    '|'.join(info_keys))
216            with open(info_file_path, 'r') as f:
217                for line in f:
218                    line = line.strip()
219                    line = line.split('#')[0]
220                    match = info_regex.match(line)
221                    if match:
222                        info[match.group(1)] = str(match.group(2)).strip()
223        return info
224
225    def _CollectEndTimes(self, status_raw, status_re='', is_end=True):
226        """Helper to match and collect timestamp and localtime.
227
228        Preferred to locate timestamp and localtime with an
229        'END GOOD test_name...' line.  However, aborted tests occasionally fail
230        to produce this line and then need to scrape timestamps from the 'START
231        test_name...' line.
232
233        @param status_raw: multi-line text to search.
234        @param status_re: status regex to seek (e.g. GOOD|FAIL)
235        @param is_end: if True, search for 'END' otherwise 'START'.
236
237        @return Tuple of timestamp, localtime retrieved from the test status
238                log.
239
240        """
241        timestamp = ''
242        localtime = ''
243
244        localtime_re = r'\w+\s+\w+\s+[:\w]+'
245        match_filter = (
246                r'^\s*%s\s+(?:%s).*timestamp=(\d*).*localtime=(%s).*$' % (
247                'END' if is_end else 'START', status_re, localtime_re))
248        matches = re.findall(match_filter, status_raw, re.MULTILINE)
249        if matches:
250            # There may be multiple lines with timestamp/localtime info.
251            # The last one found is selected because it will reflect the end
252            # time.
253            for i in xrange(len(matches)):
254                timestamp_, localtime_ = matches[-(i+1)]
255                if not timestamp or timestamp_ > timestamp:
256                    timestamp = timestamp_
257                    localtime = localtime_
258        return timestamp, localtime
259
260    def _CheckExperimental(self, testdir):
261        """Parses keyval file and return the value of `experimental`.
262
263        @param testdir: The result directory that has the keyval file.
264
265        @return The value of 'experimental', which is a boolean value indicating
266                whether it is an experimental test or not.
267
268        """
269        keyval_file = os.path.join(testdir, 'keyval')
270        if not os.path.isfile(keyval_file):
271            return False
272
273        with open(keyval_file) as f:
274            for line in f:
275                match = re.match(r'experimental=(.+)', line)
276                if match:
277                    return match.group(1) == 'True'
278            else:
279                return False
280
281
282    def _CollectResult(self, testdir, results, is_experimental=False):
283        """Collects results stored under testdir into a dictionary.
284
285        The presence/location of status files (status.log, status and
286        job_report.html) varies depending on whether the job is a simple
287        client test, simple server test, old-style suite or new-style
288        suite.  For example:
289        -In some cases a single job_report.html may exist but many times
290         multiple instances are produced in a result tree.
291        -Most tests will produce a status.log but client tests invoked
292         by a server test will only emit a status file.
293
294        The two common criteria that seem to define the presence of a
295        valid test result are:
296        1. Existence of a 'status.log' or 'status' file. Note that if both a
297             'status.log' and 'status' file exist for a test, the 'status' file
298             is always a subset of the 'status.log' fle contents.
299        2. Presence of a 'debug' directory.
300
301        In some cases multiple 'status.log' files will exist where the parent
302        'status.log' contains the contents of multiple subdirectory 'status.log'
303        files.  Parent and subdirectory 'status.log' files are always expected
304        to agree on the outcome of a given test.
305
306        The test results discovered from the 'status*' files are included
307        in the result dictionary.  The test directory name and a test directory
308        timestamp/localtime are saved to be used as sort keys for the results.
309
310        The value of 'is_experimental' is included in the result dictionary.
311
312        @param testdir: The autoserv test result directory.
313        @param results: A list to which a populated test-result-dictionary will
314                be appended if a status file is found.
315        @param is_experimental: A boolean value indicating whether the result
316                directory is for an experimental test.
317
318        """
319        status_file = os.path.join(testdir, 'status.log')
320        if not os.path.isfile(status_file):
321            status_file = os.path.join(testdir, 'status')
322            if not os.path.isfile(status_file):
323                return
324
325        # Status is True if GOOD, else False for all others.
326        status = False
327        error_msg = None
328        status_raw = open(status_file, 'r').read()
329        failure_tags = 'ABORT|ERROR|FAIL'
330        warning_tag = 'WARN|TEST_NA'
331        failure = re.search(r'%s' % failure_tags, status_raw)
332        warning = re.search(r'%s' % warning_tag, status_raw) and not failure
333        good = (re.search(r'GOOD.+completed successfully', status_raw) and
334                             not (failure or warning))
335
336        # We'd like warnings to allow the tests to pass, but still gather info.
337        if good or warning:
338            status = True
339
340        if not good:
341            match = re.search(r'^\t+(%s|%s)\t(.+)' % (failure_tags,
342                                                      warning_tag),
343                              status_raw, re.MULTILINE)
344            if match:
345                failure_type = match.group(1)
346                reason = match.group(2).split('\t')[4]
347                if self._escape_error:
348                    reason = re.escape(reason)
349                error_msg = ': '.join([failure_type, reason])
350
351        # Grab the timestamp - can be used for sorting the test runs.
352        # Grab the localtime - may be printed to enable line filtering by date.
353        # Designed to match a line like this:
354        #   END GOOD testname ... timestamp=1347324321 localtime=Sep 10 17:45:21
355        status_re = r'GOOD|%s|%s' % (failure_tags, warning_tag)
356        timestamp, localtime = self._CollectEndTimes(status_raw, status_re)
357        # Hung tests will occasionally skip printing the END line so grab
358        # a default timestamp from the START line in those cases.
359        if not timestamp:
360            timestamp, localtime = self._CollectEndTimes(status_raw,
361                                                         is_end=False)
362
363        results.append({
364                'testdir': testdir,
365                'crashes': self._CollectCrashes(status_raw),
366                'status': status,
367                'error_msg': error_msg,
368                'localtime': localtime,
369                'timestamp': timestamp,
370                'perf': self._CollectPerf(testdir),
371                'attr': self._CollectAttr(testdir),
372                'info': self._CollectInfo(testdir, {'localtime': localtime,
373                                                    'timestamp': timestamp}),
374                'experimental': is_experimental})
375
376    def RecursivelyCollectResults(self, resdir, parent_experimental_tag=False):
377        """Recursively collect results into a list of dictionaries.
378
379        Only recurses into directories that possess a 'debug' subdirectory
380        because anything else is not considered a 'test' directory.
381
382        The value of 'experimental' in keyval file is used to determine whether
383        the result is for an experimental test. If it is, all its sub
384        directories are considered to be experimental tests too.
385
386        @param resdir: results/test directory to parse results from and recurse
387                into.
388        @param parent_experimental_tag: A boolean value, used to keep track of
389                whether its parent directory is for an experimental test.
390
391        @return List of dictionaries of results.
392
393        """
394        results = []
395        is_experimental = (parent_experimental_tag or
396                           self._CheckExperimental(resdir))
397        self._CollectResult(resdir, results, is_experimental)
398        for testdir in glob.glob(os.path.join(resdir, '*')):
399            # Remove false positives that are missing a debug dir.
400            if not os.path.exists(os.path.join(testdir, 'debug')):
401                continue
402
403            results.extend(self.RecursivelyCollectResults(
404                    testdir, is_experimental))
405        return results
406
407
408class ReportGenerator(object):
409    """Collects and displays data from autoserv results directories.
410
411    This class collects status and performance data from one or more autoserv
412    result directories and generates test reports.
413    """
414
415    _KEYVAL_INDENT = 2
416    _STATUS_STRINGS = {'hr': {'pass': '[  PASSED  ]', 'fail': '[  FAILED  ]'},
417                       'csv': {'pass': 'PASS', 'fail': 'FAIL'}}
418
419    def __init__(self, options, args):
420        self._options = options
421        self._args = args
422        self._color = terminal.Color(options.color)
423        self._results = []
424
425    def _CollectAllResults(self):
426        """Parses results into the self._results list.
427
428        Builds a list (self._results) where each entry is a dictionary of
429        result data from one test (which may contain other tests). Each
430        dictionary will contain values such as: test folder, status, localtime,
431        crashes, error_msg, perf keyvals [optional], info [optional].
432
433        """
434        collector = ResultCollector(
435                collect_perf=self._options.perf,
436                collect_attr=self._options.attr,
437                collect_info=self._options.info,
438                escape_error=self._options.escape_error,
439                whitelist_chrome_crashes=self._options.whitelist_chrome_crashes)
440
441        for resdir in self._args:
442            if not os.path.isdir(resdir):
443                Die('%r does not exist', resdir)
444            self._results.extend(collector.RecursivelyCollectResults(resdir))
445
446        if not self._results:
447            Die('no test directories found')
448
449    def _GenStatusString(self, status):
450        """Given a bool indicating success or failure, return the right string.
451
452        Also takes --csv into account, returns old-style strings if it is set.
453
454        @param status: True or False, indicating success or failure.
455
456        @return The appropriate string for printing..
457
458        """
459        success = 'pass' if status else 'fail'
460        if self._options.csv:
461            return self._STATUS_STRINGS['csv'][success]
462        return self._STATUS_STRINGS['hr'][success]
463
464    def _Indent(self, msg):
465        """Given a message, indents it appropriately.
466
467        @param msg: string to indent.
468        @return indented version of msg.
469
470        """
471        return ' ' * self._KEYVAL_INDENT + msg
472
473    def _GetTestColumnWidth(self):
474        """Returns the test column width based on the test data.
475
476        The test results are aligned by discovering the longest width test
477        directory name or perf key stored in the list of result dictionaries.
478
479        @return The width for the test column.
480
481        """
482        width = 0
483        for result in self._results:
484            width = max(width, len(result['testdir']))
485            perf = result.get('perf')
486            if perf:
487                perf_key_width = len(max(perf, key=len))
488                width = max(width, perf_key_width + self._KEYVAL_INDENT)
489        return width
490
491    def _PrintDashLine(self, width):
492        """Prints a line of dashes as a separator in output.
493
494        @param width: an integer.
495        """
496        if not self._options.csv:
497            print ''.ljust(width + len(self._STATUS_STRINGS['hr']['pass']), '-')
498
499    def _PrintEntries(self, entries):
500        """Prints a list of strings, delimited based on --csv flag.
501
502        @param entries: a list of strings, entities to output.
503
504        """
505        delimiter = ',' if self._options.csv else ' '
506        print delimiter.join(entries)
507
508    def _PrintErrors(self, test, error_msg):
509        """Prints an indented error message, unless the --csv flag is set.
510
511        @param test: the name of a test with which to prefix the line.
512        @param error_msg: a message to print.  None is allowed, but ignored.
513
514        """
515        if not self._options.csv and error_msg:
516            self._PrintEntries([test, self._Indent(error_msg)])
517
518    def _PrintErrorLogs(self, test, test_string):
519        """Prints the error log for |test| if --debug is set.
520
521        @param test: the name of a test suitable for embedding in a path
522        @param test_string: the name of a test with which to prefix the line.
523
524        """
525        if self._options.print_debug:
526            debug_file_regex = os.path.join(
527                    'results.', test, 'debug',
528                    '%s*.ERROR' % os.path.basename(test))
529            for path in glob.glob(debug_file_regex):
530                try:
531                    with open(path) as fh:
532                        for line in fh:
533                            # Ensure line is not just WS.
534                            if len(line.lstrip()) <=  0:
535                                continue
536                            self._PrintEntries(
537                                    [test_string, self._Indent(line.rstrip())])
538                except IOError:
539                    print 'Could not open %s' % path
540
541    def _PrintResultDictKeyVals(self, test_entry, result_dict):
542        """Formatted print a dict of keyvals like 'perf' or 'info'.
543
544        This function emits each keyval on a single line for uncompressed
545        review.  The 'perf' dictionary contains performance keyvals while the
546        'info' dictionary contains ec info, bios info and some test timestamps.
547
548        @param test_entry: The unique name of the test (dir) - matches other
549                test output.
550        @param result_dict: A dict of keyvals to be presented.
551
552        """
553        if not result_dict:
554            return
555        dict_keys = result_dict.keys()
556        dict_keys.sort()
557        width = self._GetTestColumnWidth()
558        for dict_key in dict_keys:
559            if self._options.csv:
560                key_entry = dict_key
561            else:
562                key_entry = dict_key.ljust(width - self._KEYVAL_INDENT)
563                key_entry = key_entry.rjust(width)
564            value_entry = self._color.Color(
565                    self._color.BOLD, result_dict[dict_key])
566            self._PrintEntries([test_entry, key_entry, value_entry])
567
568    def _GetSortedTests(self):
569        """Sort the test result dicts in preparation for results printing.
570
571        By default sorts the results directionaries by their test names.
572        However, when running long suites, it is useful to see if an early test
573        has wedged the system and caused the remaining tests to abort/fail. The
574        datetime-based chronological sorting allows this view.
575
576        Uses the --sort-chron command line option to control.
577
578        """
579        if self._options.sort_chron:
580            # Need to reverse sort the test dirs to ensure the suite folder
581            # shows at the bottom. Because the suite folder shares its datetime
582            # with the last test it shows second-to-last without the reverse
583            # sort first.
584            tests = sorted(self._results, key=operator.itemgetter('testdir'),
585                           reverse=True)
586            tests = sorted(tests, key=operator.itemgetter('timestamp'))
587        else:
588            tests = sorted(self._results, key=operator.itemgetter('testdir'))
589        return tests
590
591    # TODO(zamorzaev): reuse this method in _GetResultsForHTMLReport to avoid
592    # code copying.
593    def _GetDedupedResults(self):
594        """Aggregate results from multiple retries of the same test."""
595        deduped_results = {}
596        for test in self._GetSortedTests():
597            test_details_matched = re.search(r'(.*)results-(\d[0-9]*)-(.*)',
598                                             test['testdir'])
599            if not test_details_matched:
600                continue
601
602            log_dir, test_number, test_name = test_details_matched.groups()
603            if (test_name in deduped_results and
604                deduped_results[test_name].get('status')):
605                # Already have a successfull (re)try.
606                continue
607
608            deduped_results[test_name] = test
609        return deduped_results.values()
610
611    def _GetResultsForHTMLReport(self):
612        """Return cleaned results for HTML report.!"""
613        import copy
614        tests = copy.deepcopy(self._GetSortedTests())
615        pass_tag = "Pass"
616        fail_tag = "Fail"
617        na_tag = "NA"
618        count = 0
619        html_results = {}
620        for test_status in tests:
621            individual_tc_results = {}
622            test_details_matched = re.search(r'(.*)results-(\d[0-9]*)-(.*)',
623                                             test_status['testdir'])
624            if not test_details_matched:
625                continue
626            log_dir = test_details_matched.group(1)
627            test_number = test_details_matched.group(2)
628            test_name = test_details_matched.group(3)
629            if '/' in test_name:
630                test_name = test_name.split('/')[0]
631            if test_status['error_msg'] is None:
632                test_status['error_msg'] = ''
633            if not html_results.has_key(test_name):
634                count = count + 1
635                # Arranging the results in an order
636                individual_tc_results['status'] = test_status['status']
637                individual_tc_results['error_msg'] = test_status['error_msg']
638                individual_tc_results['s_no'] = count
639                individual_tc_results['crashes'] = test_status['crashes']
640
641                # Add <b> and </b> tag for the good format in the report.
642                individual_tc_results['attempts'] = \
643                    '<b>test_result_number: %s - %s</b> : %s' % (
644                        test_number, log_dir, test_status['error_msg'])
645                html_results[test_name] = individual_tc_results
646            else:
647
648                # If test found already then we are using the previous data
649                # instead of creating two different html rows. If existing
650                # status is False then needs to be updated
651                if html_results[test_name]['status'] is False:
652                    html_results[test_name]['status'] = test_status['status']
653                    html_results[test_name]['error_msg'] = test_status[
654                        'error_msg']
655                    html_results[test_name]['crashes'] = \
656                        html_results[test_name]['crashes'] + test_status[
657                            'crashes']
658                    html_results[test_name]['attempts'] = \
659                        html_results[test_name]['attempts'] + \
660                        '</br><b>test_result_number : %s - %s</b> : %s' % (
661                            test_number, log_dir, test_status['error_msg'])
662
663        # Re-formating the dictionary as s_no as key. So that we can have
664        # ordered data at the end
665        sorted_html_results = {}
666        for key in html_results.keys():
667            sorted_html_results[str(html_results[key]['s_no'])] = \
668                    html_results[key]
669            sorted_html_results[str(html_results[key]['s_no'])]['test'] = key
670
671        # Mapping the Test case status if True->Pass, False->Fail and if
672        # True and the error message then NA
673        for key in sorted_html_results.keys():
674            if sorted_html_results[key]['status']:
675                if sorted_html_results[key]['error_msg'] != '':
676                    sorted_html_results[key]['status'] = na_tag
677                else:
678                    sorted_html_results[key]['status'] = pass_tag
679            else:
680                sorted_html_results[key]['status'] = fail_tag
681
682        return sorted_html_results
683
684    def GenerateReportHTML(self):
685        """Generate clean HTMl report for the results."""
686
687        results = self._GetResultsForHTMLReport()
688        html_table_header = """ <th>S.No</th>
689                                <th>Test</th>
690                                <th>Status</th>
691                                <th>Error Message</th>
692                                <th>Crashes</th>
693                                <th>Attempts</th>
694                            """
695        passed_tests = len([key for key in results.keys() if results[key][
696                'status'].lower() == 'pass'])
697        failed_tests = len([key for key in results.keys() if results[key][
698            'status'].lower() == 'fail'])
699        na_tests = len([key for key in results.keys() if results[key][
700            'status'].lower() == 'na'])
701        total_tests = passed_tests + failed_tests + na_tests
702
703        # Sort the keys
704        ordered_keys = sorted([int(key) for key in results.keys()])
705        html_table_body = ''
706        for key in ordered_keys:
707            key = str(key)
708            if results[key]['status'].lower() == 'pass':
709                color = 'LimeGreen'
710            elif results[key]['status'].lower() == 'na':
711                color = 'yellow'
712            else:
713                color = 'red'
714            html_table_body = html_table_body + """<tr>
715                                                    <td>%s</td>
716                                                    <td>%s</td>
717                                                    <td
718                                                    style="background-color:%s;">
719                                                    %s</td>
720                                                    <td>%s</td>
721                                                    <td>%s</td>
722                                                    <td>%s</td></tr>""" % \
723                                                (key, results[key]['test'],
724                                                 color,
725                                                 results[key]['status'],
726                                                 results[key]['error_msg'],
727                                                 results[key]['crashes'],
728                                                 results[key]['attempts'])
729        html_page = """
730                        <!DOCTYPE html>
731                        <html lang="en">
732                        <head>
733                            <title>Automation Results</title>
734                            <meta charset="utf-8">
735                            <meta name="viewport" content="width=device-width,initial-scale=1">
736                            <link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.7/css/bootstrap.min.css">
737                            <script src="https://ajax.googleapis.com/ajax/libs/jquery/3.3.1/jquery.min.js"></script>
738                            <script src="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.7/js/bootstrap.min.js"></script>
739                        </head>
740                        <body>
741                            <div class="container">
742                                <h2>Automation Report</h2>
743                                <table class="table table-bordered" border="1">
744                                    <thead>
745                                        <tr style="background-color:LightSkyBlue;">
746                                        \n%s
747                                        </tr>
748                                    </thead>
749                                    <tbody>
750                                    \n%s
751                                    </tbody>
752                                </table>
753                                <div class="row">
754                                    <div class="col-sm-4">Passed: <b>%d</b></div>
755                                    <div class="col-sm-4">Failed: <b>%d</b></div>
756                                    <div class="col-sm-4">NA: <b>%d</b></div>
757                                </div>
758                                <div class="row">
759                                    <div class="col-sm-4">Total: <b>%d</b></div>
760                                </div>
761                            </div>
762                        </body>
763                        </html>
764
765                """ % (html_table_header, html_table_body, passed_tests,
766                       failed_tests, na_tests, total_tests)
767        with open(os.path.join(self._options.html_report_dir,
768                               "test_report.html"), 'w') as html_file:
769            html_file.write(html_page)
770
771    def _GenerateReportText(self):
772        """Prints a result report to stdout.
773
774        Prints a result table to stdout. Each row of the table contains the
775        test result directory and the test result (PASS, FAIL). If the perf
776        option is enabled, each test entry is followed by perf keyval entries
777        from the test results.
778
779        """
780        tests = self._GetSortedTests()
781        width = self._GetTestColumnWidth()
782
783        crashes = {}
784        tests_pass = 0
785        self._PrintDashLine(width)
786
787        for result in tests:
788            testdir = result['testdir']
789            test_entry = testdir if self._options.csv else testdir.ljust(width)
790
791            status_entry = self._GenStatusString(result['status'])
792            if result['status']:
793                color = self._color.GREEN
794                tests_pass += 1
795            else:
796                color = self._color.RED
797
798            test_entries = [test_entry, self._color.Color(color, status_entry)]
799
800            info = result.get('info', {})
801            info.update(result.get('attr', {}))
802            if self._options.csv and (self._options.info or self._options.attr):
803                if info:
804                    test_entries.extend(['%s=%s' % (k, info[k])
805                                        for k in sorted(info.keys())])
806                if not result['status'] and result['error_msg']:
807                    test_entries.append('reason="%s"' % result['error_msg'])
808
809            self._PrintEntries(test_entries)
810            self._PrintErrors(test_entry, result['error_msg'])
811
812            # Print out error log for failed tests.
813            if not result['status']:
814                self._PrintErrorLogs(testdir, test_entry)
815
816            # Emit the perf keyvals entries. There will be no entries if the
817            # --no-perf option is specified.
818            self._PrintResultDictKeyVals(test_entry, result['perf'])
819
820            # Determine that there was a crash during this test.
821            if result['crashes']:
822                for crash in result['crashes']:
823                    if not crash in crashes:
824                        crashes[crash] = set([])
825                    crashes[crash].add(testdir)
826
827            # Emit extra test metadata info on separate lines if not --csv.
828            if not self._options.csv:
829                self._PrintResultDictKeyVals(test_entry, info)
830
831        self._PrintDashLine(width)
832
833        if not self._options.csv:
834            total_tests = len(tests)
835            percent_pass = 100 * tests_pass / total_tests
836            pass_str = '%d/%d (%d%%)' % (tests_pass, total_tests, percent_pass)
837            print 'Total PASS: ' + self._color.Color(self._color.BOLD, pass_str)
838
839        if self._options.crash_detection:
840            print ''
841            if crashes:
842                print self._color.Color(self._color.RED,
843                                        'Crashes detected during testing:')
844                self._PrintDashLine(width)
845
846                for crash_name, crashed_tests in sorted(crashes.iteritems()):
847                    print self._color.Color(self._color.RED, crash_name)
848                    for crashed_test in crashed_tests:
849                        print self._Indent(crashed_test)
850
851                self._PrintDashLine(width)
852                print ('Total unique crashes: ' +
853                       self._color.Color(self._color.BOLD, str(len(crashes))))
854
855            # Sometimes the builders exit before these buffers are flushed.
856            sys.stderr.flush()
857            sys.stdout.flush()
858
859    def Run(self):
860        """Runs report generation."""
861        self._CollectAllResults()
862        if not self._options.just_status_code:
863            self._GenerateReportText()
864            if self._options.html:
865                print "\nLogging the data into test_report.html file."
866                try:
867                    self.GenerateReportHTML()
868                except Exception as e:
869                    print "Failed to generate HTML report %s" % str(e)
870        for d in self._GetDedupedResults():
871            if d['experimental'] and self._options.ignore_experimental_tests:
872                continue
873            if not d['status'] or (
874                    self._options.crash_detection and d['crashes']):
875                sys.exit(1)
876
877
878def main():
879    usage = 'Usage: %prog [options] result-directories...'
880    parser = optparse.OptionParser(usage=usage)
881    parser.add_option('--color', dest='color', action='store_true',
882                      default=_STDOUT_IS_TTY,
883                      help='Use color for text reports [default if TTY stdout]')
884    parser.add_option('--no-color', dest='color', action='store_false',
885                      help='Don\'t use color for text reports')
886    parser.add_option('--no-crash-detection', dest='crash_detection',
887                      action='store_false', default=True,
888                      help='Don\'t report crashes or error out when detected')
889    parser.add_option('--csv', dest='csv', action='store_true',
890                      help='Output test result in CSV format.  '
891                      'Implies --no-debug --no-crash-detection.')
892    parser.add_option('--html', dest='html', action='store_true',
893                      help='To generate HTML File.  '
894                           'Implies --no-debug --no-crash-detection.')
895    parser.add_option('--html-report-dir', dest='html_report_dir',
896                      action='store', default=None, help='Path to generate '
897                                                          'html report')
898    parser.add_option('--info', dest='info', action='store_true',
899                      default=False,
900                      help='Include info keyvals in the report')
901    parser.add_option('--escape-error', dest='escape_error',
902                      action='store_true', default=False,
903                      help='Escape error message text for tools.')
904    parser.add_option('--perf', dest='perf', action='store_true',
905                      default=True,
906                      help='Include perf keyvals in the report [default]')
907    parser.add_option('--attr', dest='attr', action='store_true',
908                      default=False,
909                      help='Include attr keyvals in the report')
910    parser.add_option('--no-perf', dest='perf', action='store_false',
911                      help='Don\'t include perf keyvals in the report')
912    parser.add_option('--sort-chron', dest='sort_chron', action='store_true',
913                      default=False,
914                      help='Sort results by datetime instead of by test name.')
915    parser.add_option('--no-debug', dest='print_debug', action='store_false',
916                      default=True,
917                      help='Don\'t print out logs when tests fail.')
918    parser.add_option('--whitelist_chrome_crashes',
919                      dest='whitelist_chrome_crashes',
920                      action='store_true', default=False,
921                      help='Treat Chrome crashes as non-fatal.')
922    parser.add_option('--ignore_experimental_tests',
923                      dest='ignore_experimental_tests',
924                      action='store_true', default=False,
925                      help='If set, experimental test results will not '
926                           'influence the exit code.')
927    parser.add_option('--just_status_code',
928                      dest='just_status_code',
929                      action='store_true', default=False,
930                      help='Skip generating a report, just return status code.')
931
932    (options, args) = parser.parse_args()
933
934    if not args:
935        parser.print_help()
936        Die('no result directories provided')
937
938    if options.csv and (options.print_debug or options.crash_detection):
939        Warning('Forcing --no-debug --no-crash-detection')
940        options.print_debug = False
941        options.crash_detection = False
942
943    report_options = ['color', 'csv', 'info', 'escape_error', 'perf', 'attr',
944                      'sort_chron', 'print_debug', 'html', 'html_report_dir']
945    if options.just_status_code and any(
946        getattr(options, opt) for opt in report_options):
947        Warning('Passed --just_status_code and incompatible options %s' %
948                ' '.join(opt for opt in report_options if getattr(options,opt)))
949
950    generator = ReportGenerator(options, args)
951    generator.Run()
952
953
954if __name__ == '__main__':
955    main()
956