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    def _GenerateReportText(self):
592        """Prints a result report to stdout.
593
594        Prints a result table to stdout. Each row of the table contains the
595        test result directory and the test result (PASS, FAIL). If the perf
596        option is enabled, each test entry is followed by perf keyval entries
597        from the test results.
598
599        """
600        tests = self._GetSortedTests()
601        width = self._GetTestColumnWidth()
602
603        crashes = {}
604        tests_pass = 0
605        self._PrintDashLine(width)
606
607        for result in tests:
608            testdir = result['testdir']
609            test_entry = testdir if self._options.csv else testdir.ljust(width)
610
611            status_entry = self._GenStatusString(result['status'])
612            if result['status']:
613                color = self._color.GREEN
614                tests_pass += 1
615            else:
616                color = self._color.RED
617
618            test_entries = [test_entry, self._color.Color(color, status_entry)]
619
620            info = result.get('info', {})
621            info.update(result.get('attr', {}))
622            if self._options.csv and (self._options.info or self._options.attr):
623                if info:
624                    test_entries.extend(['%s=%s' % (k, info[k])
625                                        for k in sorted(info.keys())])
626                if not result['status'] and result['error_msg']:
627                    test_entries.append('reason="%s"' % result['error_msg'])
628
629            self._PrintEntries(test_entries)
630            self._PrintErrors(test_entry, result['error_msg'])
631
632            # Print out error log for failed tests.
633            if not result['status']:
634                self._PrintErrorLogs(testdir, test_entry)
635
636            # Emit the perf keyvals entries. There will be no entries if the
637            # --no-perf option is specified.
638            self._PrintResultDictKeyVals(test_entry, result['perf'])
639
640            # Determine that there was a crash during this test.
641            if result['crashes']:
642                for crash in result['crashes']:
643                    if not crash in crashes:
644                        crashes[crash] = set([])
645                    crashes[crash].add(testdir)
646
647            # Emit extra test metadata info on separate lines if not --csv.
648            if not self._options.csv:
649                self._PrintResultDictKeyVals(test_entry, info)
650
651        self._PrintDashLine(width)
652
653        if not self._options.csv:
654            total_tests = len(tests)
655            percent_pass = 100 * tests_pass / total_tests
656            pass_str = '%d/%d (%d%%)' % (tests_pass, total_tests, percent_pass)
657            print 'Total PASS: ' + self._color.Color(self._color.BOLD, pass_str)
658
659        if self._options.crash_detection:
660            print ''
661            if crashes:
662                print self._color.Color(self._color.RED,
663                                        'Crashes detected during testing:')
664                self._PrintDashLine(width)
665
666                for crash_name, crashed_tests in sorted(crashes.iteritems()):
667                    print self._color.Color(self._color.RED, crash_name)
668                    for crashed_test in crashed_tests:
669                        print self._Indent(crashed_test)
670
671                self._PrintDashLine(width)
672                print ('Total unique crashes: ' +
673                       self._color.Color(self._color.BOLD, str(len(crashes))))
674
675            # Sometimes the builders exit before these buffers are flushed.
676            sys.stderr.flush()
677            sys.stdout.flush()
678
679    def Run(self):
680        """Runs report generation."""
681        self._CollectAllResults()
682        self._GenerateReportText()
683        for d in self._results:
684            if d['experimental'] and self._options.ignore_experimental_tests:
685                continue
686            if not d['status'] or (
687                    self._options.crash_detection and d['crashes']):
688                sys.exit(1)
689
690
691def main():
692    usage = 'Usage: %prog [options] result-directories...'
693    parser = optparse.OptionParser(usage=usage)
694    parser.add_option('--color', dest='color', action='store_true',
695                      default=_STDOUT_IS_TTY,
696                      help='Use color for text reports [default if TTY stdout]')
697    parser.add_option('--no-color', dest='color', action='store_false',
698                      help='Don\'t use color for text reports')
699    parser.add_option('--no-crash-detection', dest='crash_detection',
700                      action='store_false', default=True,
701                      help='Don\'t report crashes or error out when detected')
702    parser.add_option('--csv', dest='csv', action='store_true',
703                      help='Output test result in CSV format.  '
704                      'Implies --no-debug --no-crash-detection.')
705    parser.add_option('--info', dest='info', action='store_true',
706                      default=False,
707                      help='Include info keyvals in the report')
708    parser.add_option('--escape-error', dest='escape_error',
709                      action='store_true', default=False,
710                      help='Escape error message text for tools.')
711    parser.add_option('--perf', dest='perf', action='store_true',
712                      default=True,
713                      help='Include perf keyvals in the report [default]')
714    parser.add_option('--attr', dest='attr', action='store_true',
715                      default=False,
716                      help='Include attr keyvals in the report')
717    parser.add_option('--no-perf', dest='perf', action='store_false',
718                      help='Don\'t include perf keyvals in the report')
719    parser.add_option('--sort-chron', dest='sort_chron', action='store_true',
720                      default=False,
721                      help='Sort results by datetime instead of by test name.')
722    parser.add_option('--no-debug', dest='print_debug', action='store_false',
723                      default=True,
724                      help='Don\'t print out logs when tests fail.')
725    parser.add_option('--whitelist_chrome_crashes',
726                      dest='whitelist_chrome_crashes',
727                      action='store_true', default=False,
728                      help='Treat Chrome crashes as non-fatal.')
729    parser.add_option('--ignore_experimental_tests',
730                      dest='ignore_experimental_tests',
731                      action='store_true', default=False,
732                      help='If set, experimental test results will not '
733                           'influence the exit code.')
734
735    (options, args) = parser.parse_args()
736
737    if not args:
738        parser.print_help()
739        Die('no result directories provided')
740
741    if options.csv and (options.print_debug or options.crash_detection):
742        Warning('Forcing --no-debug --no-crash-detection')
743        options.print_debug = False
744        options.crash_detection = False
745
746    generator = ReportGenerator(options, args)
747    generator.Run()
748
749
750if __name__ == '__main__':
751    main()
752