Home | History | Annotate | Download | only in server
      1 # Copyright (c) 2011 The Chromium OS Authors. All rights reserved.
      2 # Use of this source code is governed by a BSD-style license that can be
      3 # found in the LICENSE file.
      4 
      5 """Utility classes used to run and parse a gest suite in autotest.
      6 
      7 gtest_runner: runs a gtest suite parsing individual tests.
      8 
      9 gtest_parser: understands the output of a gtest run.
     10 """
     11 
     12 
     13 import logging, os, re
     14 from autotest_lib.server import autotest, hosts, host_attributes
     15 from autotest_lib.server import site_server_job_utils
     16 
     17 
     18 class gtest_runner(object):
     19     """Run a gtest test suite and evaluate the individual tests."""
     20 
     21     def __init__(self):
     22         """Creates an instance of gtest_runner to run tests on a remote host."""
     23         self._results_dir = ''
     24         self._gtest = None
     25         self._host = None
     26 
     27     def run(self, gtest_entry, machine, work_dir='.'):
     28         """Run the gtest suite on a remote host, then parse the results.
     29 
     30         Like machine_worker, gtest_runner honors include/exclude attributes on
     31         the test item and will only run the test if the supplied host meets the
     32         test requirements.
     33 
     34         Note: This method takes a test and a machine as arguments, not a list
     35         of tests and a list of machines like the parallel and distribute
     36         methods do.
     37 
     38         Args:
     39             gtest_entry: Test tuple from control file.  See documentation in
     40                 site_server_job_utils.test_item class.
     41             machine: Name (IP) if remote host to run tests on.
     42             work_dir: Local directory to run tests in.
     43 
     44         """
     45         self._gtest = site_server_job_utils.test_item(*gtest_entry)
     46         self._host = hosts.create_host(machine)
     47         self._results_dir = work_dir
     48 
     49         client_autotest = autotest.Autotest(self._host)
     50         client_attributes = host_attributes.host_attributes(machine)
     51         attribute_set = set(client_attributes.get_attributes())
     52 
     53         if self._gtest.validate(attribute_set):
     54             logging.info('%s %s Running %s', self._host,
     55                          [a for a in attribute_set], self._gtest)
     56             try:
     57                 self._gtest.run_test(client_autotest, self._results_dir)
     58             finally:
     59                 self.parse()
     60         else:
     61             self.record_failed_test(self._gtest.test_name,
     62                                     'No machines found for: ' + self._gtest)
     63 
     64     def parse(self):
     65         """Parse the gtest output recording individual test results.
     66 
     67         Uses gtest_parser to pull the test results out of the gtest log file.
     68         Then creates entries  in status.log file for each test.
     69         """
     70         # Find gtest log files from the autotest client run.
     71         log_path = os.path.join(
     72             self._results_dir, self._gtest.tagged_test_name,
     73             'debug', self._gtest.tagged_test_name + '.DEBUG')
     74         if not os.path.exists(log_path):
     75             logging.error('gtest log file "%s" is missing.', log_path)
     76             return
     77 
     78         parser = gtest_parser()
     79 
     80         # Read the log file line-by-line, passing each line into the parser.
     81         with open(log_path, 'r') as log_file:
     82             for log_line in log_file:
     83                 parser.ProcessLogLine(log_line)
     84 
     85         logging.info('gtest_runner found %d tests.', parser.TotalTests())
     86 
     87         # Record each failed test.
     88         for failed in parser.FailedTests():
     89             fail_description = parser.FailureDescription(failed)
     90             if fail_description:
     91                 self.record_failed_test(failed, fail_description[0].strip(),
     92                                         ''.join(fail_description))
     93             else:
     94                 self.record_failed_test(failed, 'NO ERROR LINES FOUND.')
     95 
     96         # Finally record each successful test.
     97         for passed in parser.PassedTests():
     98             self.record_passed_test(passed)
     99 
    100     def record_failed_test(self, failed_test, message, error_lines=None):
    101         """Insert a failure record into status.log for this test.
    102 
    103         Args:
    104            failed_test: Name of test that failed.
    105            message: Reason test failed, will be put in status.log file.
    106            error_lines: Additional failure info, will be put in ERROR log.
    107         """
    108         # Create a test name subdirectory to hold the test status.log file.
    109         test_dir = os.path.join(self._results_dir, failed_test)
    110         if not os.path.exists(test_dir):
    111             try:
    112                 os.makedirs(test_dir)
    113             except OSError:
    114                 logging.exception('Failed to created test directory: %s',
    115                                   test_dir)
    116 
    117         # Record failure into the global job and test specific status files.
    118         self._host.record('START', failed_test, failed_test)
    119         self._host.record('INFO', failed_test, 'FAILED: ' + failed_test)
    120         self._host.record('END FAIL', failed_test, failed_test, message)
    121 
    122         # If we have additional information on the failure, create an error log
    123         # file for this test in the location a normal autotest would have left
    124         # it so the frontend knows where to find it.
    125         if error_lines is not None:
    126             fail_log_dir = os.path.join(test_dir, 'debug')
    127             fail_log_path = os.path.join(fail_log_dir, failed_test + '.ERROR')
    128 
    129             if not os.path.exists(fail_log_dir):
    130                 try:
    131                     os.makedirs(fail_log_dir)
    132                 except OSError:
    133                     logging.exception('Failed to created log directory: %s',
    134                                       fail_log_dir)
    135                     return
    136             try:
    137                 with open(fail_log_path, 'w') as fail_log:
    138                     fail_log.write(error_lines)
    139             except IOError:
    140                 logging.exception('Failed to open log file: %s', fail_log_path)
    141 
    142     def record_passed_test(self, passed_test):
    143         """Insert a failure record into status.log for this test.
    144 
    145         Args:
    146             passed_test: Name of test that passed.
    147         """
    148         self._host.record('START', None, passed_test)
    149         self._host.record('INFO', None, 'PASSED: ' + passed_test)
    150         self._host.record('END GOOD', None, passed_test)
    151 
    152 
    153 class gtest_parser(object):
    154     """This class knows how to understand GTest test output.
    155 
    156     The code was borrowed with minor changes from chrome utility gtest_command.
    157         http://src.chromium.org/viewvc/chrome/trunk/tools/build/scripts/master/
    158         log_parser/gtest_command.py?view=markup
    159     """
    160 
    161     def __init__(self):
    162         # State tracking for log parsing
    163         self._current_test = ''
    164         self._failure_description = []
    165         self._current_suppression_hash = ''
    166         self._current_suppression = []
    167 
    168         # Line number currently being processed.
    169         self._line_number = 0
    170 
    171         # List of parsing errors, as human-readable strings.
    172         self.internal_error_lines = []
    173 
    174         # Tests are stored here as 'test.name': (status, [description]).
    175         # The status should be one of ('started', 'OK', 'failed', 'timeout').
    176         # The description is a list of lines detailing the test's error, as
    177         # reported in the log.
    178         self._test_status = {}
    179 
    180         # Suppressions are stored here as 'hash': [suppression].
    181         self._suppressions = {}
    182 
    183         # This may be either text or a number. It will be used in the phrase
    184         # '%s disabled' or '%s flaky' on the waterfall display.
    185         self.disabled_tests = 0
    186         self.flaky_tests = 0
    187 
    188         # Regular expressions for parsing GTest logs. Test names look like
    189         #   SomeTestCase.SomeTest
    190         #   SomeName/SomeTestCase.SomeTest/1
    191         # This regexp also matches SomeName.SomeTest/1, which should be
    192         # harmless.
    193         test_name_regexp = r'((\w+/)?\w+\.\w+(\.\w+)?(/\d+)?)'
    194         self._test_start = re.compile('\[\s+RUN\s+\] ' + test_name_regexp)
    195         self._test_ok = re.compile('\[\s+OK\s+\] ' + test_name_regexp)
    196         self._test_fail = re.compile('\[\s+FAILED\s+\] ' + test_name_regexp)
    197         self._test_timeout = re.compile(
    198             'Test timeout \([0-9]+ ms\) exceeded for ' + test_name_regexp)
    199         self._disabled = re.compile('  YOU HAVE (\d+) DISABLED TEST')
    200         self._flaky = re.compile('  YOU HAVE (\d+) FLAKY TEST')
    201 
    202         self._suppression_start = re.compile(
    203             'Suppression \(error hash=#([0-9A-F]+)#\):')
    204         self._suppression_end = re.compile('^}\s*$')
    205 
    206         self._master_name_re = re.compile('\[Running for master: "([^"]*)"')
    207         self.master_name = ''
    208 
    209         self._error_logging_start_re = re.compile('=' * 70)
    210         self._error_logging_test_name_re = re.compile(
    211             '[FAIL|ERROR]: ' + test_name_regexp)
    212         self._error_logging_end_re = re.compile('-' * 70)
    213         self._error_logging_first_dash_found = False
    214 
    215     def _TestsByStatus(self, status, include_fails, include_flaky):
    216         """Returns list of tests with the given status.
    217 
    218         Args:
    219             status: test results status to search for.
    220             include_fails: If False, tests containing 'FAILS_' anywhere in
    221                 their names will be excluded from the list.
    222             include_flaky: If False, tests containing 'FLAKY_' anywhere in
    223                 their names will be excluded from the list.
    224         Returns:
    225             List of tests with the status.
    226         """
    227         test_list = [x[0] for x in self._test_status.items()
    228                      if self._StatusOfTest(x[0]) == status]
    229 
    230         if not include_fails:
    231             test_list = [x for x in test_list if x.find('FAILS_') == -1]
    232         if not include_flaky:
    233             test_list = [x for x in test_list if x.find('FLAKY_') == -1]
    234 
    235         return test_list
    236 
    237     def _StatusOfTest(self, test):
    238         """Returns the status code for the given test, or 'not known'."""
    239         test_status = self._test_status.get(test, ('not known', []))
    240         return test_status[0]
    241 
    242     def _RecordError(self, line, reason):
    243         """Record a log line that produced a parsing error.
    244 
    245         Args:
    246             line: text of the line at which the error occurred.
    247             reason: a string describing the error.
    248         """
    249         self.internal_error_lines.append("%s: %s [%s]" % (self._line_number,
    250                                                           line.strip(),
    251                                                           reason))
    252 
    253     def TotalTests(self):
    254         """Returns the number of parsed tests."""
    255         return len(self._test_status)
    256 
    257     def PassedTests(self):
    258         """Returns list of tests that passed."""
    259         return self._TestsByStatus('OK', False, False)
    260 
    261     def FailedTests(self, include_fails=False, include_flaky=False):
    262         """Returns list of tests that failed, timed out, or didn't finish.
    263 
    264         This list will be incorrect until the complete log has been processed,
    265         because it will show currently running tests as having failed.
    266 
    267         Args:
    268             include_fails: If true, all failing tests with FAILS_ in their
    269                 names will be included. Otherwise, they will only be included
    270                 if they crashed.
    271             include_flaky: If true, all failing tests with FLAKY_ in their
    272                 names will be included. Otherwise, they will only be included
    273                 if they crashed.
    274         Returns:
    275             List of failed tests.
    276         """
    277         return (self._TestsByStatus('failed', include_fails, include_flaky) +
    278                 self._TestsByStatus('timeout', include_fails, include_flaky) +
    279                 self._TestsByStatus('started', include_fails, include_flaky))
    280 
    281     def FailureDescription(self, test):
    282         """Returns a list containing the failure description for the given test.
    283 
    284         If the test didn't fail or timeout, returns [].
    285         Args:
    286             test: Name to test to find failure reason.
    287         Returns:
    288             List of test name, and failure string.
    289         """
    290         test_status = self._test_status.get(test, ('', []))
    291         return test_status[1]
    292 
    293     def SuppressionHashes(self):
    294         """Returns list of suppression hashes found in the log."""
    295         return self._suppressions.keys()
    296 
    297     def Suppression(self, suppression_hash):
    298         """Returns a list containing the suppression for a given hash.
    299 
    300         If the suppression hash doesn't exist, returns [].
    301 
    302         Args:
    303             suppression_hash: name of hash.
    304         Returns:
    305             List of suppression for the hash.
    306         """
    307         return self._suppressions.get(suppression_hash, [])
    308 
    309     def ProcessLogLine(self, line):
    310         """This is called once with each line of the test log."""
    311 
    312         # Track line number for error messages.
    313         self._line_number += 1
    314 
    315         if not self.master_name:
    316             results = self._master_name_re.search(line)
    317             if results:
    318                 self.master_name = results.group(1)
    319 
    320         # Is it a line reporting disabled tests?
    321         results = self._disabled.search(line)
    322         if results:
    323             try:
    324                 disabled = int(results.group(1))
    325             except ValueError:
    326                 disabled = 0
    327             if disabled > 0 and isinstance(self.disabled_tests, int):
    328                 self.disabled_tests += disabled
    329             else:
    330                 # If we can't parse the line, at least give a heads-up. This is
    331                 # a safety net for a case that shouldn't happen but isn't a
    332                 # fatal error.
    333                 self.disabled_tests = 'some'
    334             return
    335 
    336         # Is it a line reporting flaky tests?
    337         results = self._flaky.search(line)
    338         if results:
    339             try:
    340                 flaky = int(results.group(1))
    341             except ValueError:
    342                 flaky = 0
    343             if flaky > 0 and isinstance(self.flaky_tests, int):
    344                 self.flaky_tests = flaky
    345             else:
    346                 # If we can't parse the line, at least give a heads-up. This is
    347                 # a safety net for a case that shouldn't happen but isn't a
    348                 # fatal error.
    349                 self.flaky_tests = 'some'
    350             return
    351 
    352         # Is it the start of a test?
    353         results = self._test_start.search(line)
    354         if results:
    355             test_name = results.group(1)
    356             if test_name in self._test_status:
    357                 self._RecordError(line, 'test started more than once')
    358                 return
    359             if self._current_test:
    360                 status = self._StatusOfTest(self._current_test)
    361                 if status in ('OK', 'failed', 'timeout'):
    362                     self._RecordError(line, 'start while in status %s' % status)
    363                     return
    364                 if status not in ('failed', 'timeout'):
    365                     self._test_status[self._current_test] = (
    366                         'failed', self._failure_description)
    367             self._test_status[test_name] = ('started', ['Did not complete.'])
    368             self._current_test = test_name
    369             self._failure_description = []
    370             return
    371 
    372         # Is it a test success line?
    373         results = self._test_ok.search(line)
    374         if results:
    375             test_name = results.group(1)
    376             status = self._StatusOfTest(test_name)
    377             if status != 'started':
    378                 self._RecordError(line, 'success while in status %s' % status)
    379                 return
    380             self._test_status[test_name] = ('OK', [])
    381             self._failure_description = []
    382             self._current_test = ''
    383             return
    384 
    385         # Is it a test failure line?
    386         results = self._test_fail.search(line)
    387         if results:
    388             test_name = results.group(1)
    389             status = self._StatusOfTest(test_name)
    390             if status not in ('started', 'failed', 'timeout'):
    391                 self._RecordError(line, 'failure while in status %s' % status)
    392                 return
    393             # Don't overwrite the failure description when a failing test is
    394             # listed a second time in the summary, or if it was already
    395             # recorded as timing out.
    396             if status not in ('failed', 'timeout'):
    397                 self._test_status[test_name] = ('failed',
    398                                                 self._failure_description)
    399             self._failure_description = []
    400             self._current_test = ''
    401             return
    402 
    403         # Is it a test timeout line?
    404         results = self._test_timeout.search(line)
    405         if results:
    406             test_name = results.group(1)
    407             status = self._StatusOfTest(test_name)
    408             if status not in ('started', 'failed'):
    409                 self._RecordError(line, 'timeout while in status %s' % status)
    410                 return
    411             self._test_status[test_name] = (
    412                 'timeout', self._failure_description + ['Killed (timed out).'])
    413             self._failure_description = []
    414             self._current_test = ''
    415             return
    416 
    417         # Is it the start of a new valgrind suppression?
    418         results = self._suppression_start.search(line)
    419         if results:
    420             suppression_hash = results.group(1)
    421             if suppression_hash in self._suppressions:
    422                 self._RecordError(line, 'suppression reported more than once')
    423                 return
    424             self._suppressions[suppression_hash] = []
    425             self._current_suppression_hash = suppression_hash
    426             self._current_suppression = [line]
    427             return
    428 
    429         # Is it the end of a valgrind suppression?
    430         results = self._suppression_end.search(line)
    431         if results and self._current_suppression_hash:
    432             self._current_suppression.append(line)
    433             self._suppressions[self._current_suppression_hash] = (
    434                 self._current_suppression)
    435             self._current_suppression_hash = ''
    436             self._current_suppression = []
    437             return
    438 
    439         # Is it the start of a test summary error message?
    440         results = self._error_logging_test_name_re.search(line)
    441         if results:
    442             test_name = results.group(1)
    443             self._test_status[test_name] = ('failed', ['Output not found.'])
    444             self._current_test = test_name
    445             self._failure_description = []
    446             self._error_logging_first_dash_found = False
    447             return
    448 
    449         # Is it the start of the next test summary signaling the end
    450         # of the previous message?
    451         results = self._error_logging_start_re.search(line)
    452         if results and self._current_test:
    453             self._test_status[self._current_test] = ('failed',
    454                                                      self._failure_description)
    455             self._failure_description = []
    456             self._current_test = ''
    457             return
    458 
    459         # Is it the end of the extra test failure summaries?
    460         results = self._error_logging_end_re.search(line)
    461         if results and self._current_test:
    462             if self._error_logging_first_dash_found:
    463                 self._test_status[self._current_test] = (
    464                     'failed', self._failure_description)
    465                 self._failure_description = []
    466                 self._current_test = ''
    467             self._error_logging_first_dash_found = True
    468             return
    469 
    470         # Random line: if we're in a suppression, collect it. Suppressions are
    471         # generated after all tests are finished, so this should always belong
    472         # to the current suppression hash.
    473         if self._current_suppression_hash:
    474             self._current_suppression.append(line)
    475             return
    476 
    477         # Random line: if we're in a test, collect it for the failure
    478         # description. Tests may run simultaneously, so this might be off, but
    479         # it's worth a try.
    480         if self._current_test:
    481             self._failure_description.append(line)
    482