Paul Pendlebury | 7c1fdcf | 2011-05-04 12:39:15 -0700 | [diff] [blame^] | 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 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 | 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 = 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 | self._gtest.run_test(client_autotest, self._results_dir) |
| 57 | self.parse() |
| 58 | else: |
| 59 | self.record_failed_test(self._gtest.test_name, |
| 60 | 'No machines found for: ' + self._gtest) |
| 61 | |
| 62 | def parse(self): |
| 63 | """Parse the gtest output recording individual test results. |
| 64 | |
| 65 | Uses gtest_parser to pull the test results out of the gtest log file. |
| 66 | Then creates entries in status.log file for each test. |
| 67 | """ |
| 68 | parser = gtest_parser() |
| 69 | |
| 70 | # Find gtest log files from the autotest client run. |
| 71 | log_path = os.path.join(self._results_dir, self._gtest.test_name, |
| 72 | 'debug', self._gtest.test_name + '.DEBUG') |
| 73 | |
| 74 | # Read the log file line-by-line, passing each line into the parser. |
| 75 | with open(log_path, 'r') as log_file: |
| 76 | for log_line in log_file: |
| 77 | parser.ProcessLogLine(log_line) |
| 78 | |
| 79 | logging.info('gtest_runner found %d tests.', parser.TotalTests()) |
| 80 | |
| 81 | # Record each failed test. |
| 82 | for failed in parser.FailedTests(): |
| 83 | fail_description = parser.FailureDescription(failed) |
| 84 | self.record_failed_test(failed, fail_description[1].strip(), |
| 85 | ''.join(fail_description[1:])) |
| 86 | |
| 87 | # Finally record each successful test. |
| 88 | for passed in parser.PassedTests(): |
| 89 | self.record_passed_test(passed) |
| 90 | |
| 91 | def record_failed_test(self, failed_test, message, error_lines=None): |
| 92 | """Insert a failure record into status.log for this test. |
| 93 | |
| 94 | Args: |
| 95 | failed_test: Name of test that failed. |
| 96 | message: Reason test failed, will be put in status.log file. |
| 97 | error_lines: Additional failure info, will be put in ERROR log. |
| 98 | """ |
| 99 | self._host.record('START', None, failed_test) |
| 100 | self._host.record('INFO', None, 'FAILED: ' + failed_test) |
| 101 | self._host.record('END FAIL', None, failed_test, message) |
| 102 | |
| 103 | # If we have additional information on the failure, create an error log |
| 104 | # file for this test in the location a normal autotest would have left |
| 105 | # it so the frontend knows where to find it. |
| 106 | if error_lines is not None: |
| 107 | fail_log_dir = os.path.join(self._results_dir, failed_test, 'debug') |
| 108 | fail_log_path = os.path.join(fail_log_dir, failed_test + '.ERROR') |
| 109 | |
| 110 | if not os.path.exists(fail_log_dir): |
| 111 | try: |
| 112 | os.makedirs(fail_log_dir) |
| 113 | except OSError: |
| 114 | logging.exception('Failed to created log directory: %s', |
| 115 | fail_log_dir) |
| 116 | return |
| 117 | try: |
| 118 | with open(fail_log_path, 'w') as fail_log: |
| 119 | fail_log.write(error_lines) |
| 120 | except IOError: |
| 121 | logging.exception('Failed to open log file: %s', fail_log_path) |
| 122 | |
| 123 | def record_passed_test(self, passed_test): |
| 124 | """Insert a failure record into status.log for this test. |
| 125 | |
| 126 | Args: |
| 127 | passed_test: Name of test that passed. |
| 128 | """ |
| 129 | self._host.record('START', None, passed_test) |
| 130 | self._host.record('INFO', None, 'PASSED: ' + passed_test) |
| 131 | self._host.record('END GOOD', None, passed_test) |
| 132 | |
| 133 | |
| 134 | class gtest_parser(object): |
| 135 | """This class knows how to understand GTest test output. |
| 136 | |
| 137 | The code was borrowed with minor changes from chrome utility gtest_command. |
| 138 | http://src.chromium.org/viewvc/chrome/trunk/tools/build/scripts/master/ |
| 139 | log_parser/gtest_command.py?view=markup |
| 140 | """ |
| 141 | |
| 142 | def __init__(self): |
| 143 | # State tracking for log parsing |
| 144 | self._current_test = '' |
| 145 | self._failure_description = [] |
| 146 | self._current_suppression_hash = '' |
| 147 | self._current_suppression = [] |
| 148 | |
| 149 | # Line number currently being processed. |
| 150 | self._line_number = 0 |
| 151 | |
| 152 | # List of parsing errors, as human-readable strings. |
| 153 | self.internal_error_lines = [] |
| 154 | |
| 155 | # Tests are stored here as 'test.name': (status, [description]). |
| 156 | # The status should be one of ('started', 'OK', 'failed', 'timeout'). |
| 157 | # The description is a list of lines detailing the test's error, as |
| 158 | # reported in the log. |
| 159 | self._test_status = {} |
| 160 | |
| 161 | # Suppressions are stored here as 'hash': [suppression]. |
| 162 | self._suppressions = {} |
| 163 | |
| 164 | # This may be either text or a number. It will be used in the phrase |
| 165 | # '%s disabled' or '%s flaky' on the waterfall display. |
| 166 | self.disabled_tests = 0 |
| 167 | self.flaky_tests = 0 |
| 168 | |
| 169 | # Regular expressions for parsing GTest logs. Test names look like |
| 170 | # SomeTestCase.SomeTest |
| 171 | # SomeName/SomeTestCase.SomeTest/1 |
| 172 | # This regexp also matches SomeName.SomeTest/1, which should be |
| 173 | # harmless. |
| 174 | test_name_regexp = r'((\w+/)?\w+\.\w+(\.\w+)?(/\d+)?)' |
| 175 | self._test_start = re.compile('\[\s+RUN\s+\] ' + test_name_regexp) |
| 176 | self._test_ok = re.compile('\[\s+OK\s+\] ' + test_name_regexp) |
| 177 | self._test_fail = re.compile('\[\s+FAILED\s+\] ' + test_name_regexp) |
| 178 | self._test_timeout = re.compile( |
| 179 | 'Test timeout \([0-9]+ ms\) exceeded for ' + test_name_regexp) |
| 180 | self._disabled = re.compile(' YOU HAVE (\d+) DISABLED TEST') |
| 181 | self._flaky = re.compile(' YOU HAVE (\d+) FLAKY TEST') |
| 182 | |
| 183 | self._suppression_start = re.compile( |
| 184 | 'Suppression \(error hash=#([0-9A-F]+)#\):') |
| 185 | self._suppression_end = re.compile('^}\s*$') |
| 186 | |
| 187 | self._master_name_re = re.compile('\[Running for master: "([^"]*)"') |
| 188 | self.master_name = '' |
| 189 | |
| 190 | def _TestsByStatus(self, status, include_fails, include_flaky): |
| 191 | """Returns list of tests with the given status. |
| 192 | |
| 193 | Args: |
| 194 | status: test results status to search for. |
| 195 | include_fails: If False, tests containing 'FAILS_' anywhere in |
| 196 | their names will be excluded from the list. |
| 197 | include_flaky: If False, tests containing 'FLAKY_' anywhere in |
| 198 | their names will be excluded from the list. |
| 199 | Returns: |
| 200 | List of tests with the status. |
| 201 | """ |
| 202 | test_list = [x[0] for x in self._test_status.items() |
| 203 | if self._StatusOfTest(x[0]) == status] |
| 204 | |
| 205 | if not include_fails: |
| 206 | test_list = [x for x in test_list if x.find('FAILS_') == -1] |
| 207 | if not include_flaky: |
| 208 | test_list = [x for x in test_list if x.find('FLAKY_') == -1] |
| 209 | |
| 210 | return test_list |
| 211 | |
| 212 | def _StatusOfTest(self, test): |
| 213 | """Returns the status code for the given test, or 'not known'.""" |
| 214 | test_status = self._test_status.get(test, ('not known', [])) |
| 215 | return test_status[0] |
| 216 | |
| 217 | def _RecordError(self, line, reason): |
| 218 | """Record a log line that produced a parsing error. |
| 219 | |
| 220 | Args: |
| 221 | line: text of the line at which the error occurred. |
| 222 | reason: a string describing the error. |
| 223 | """ |
| 224 | self.internal_error_lines.append("%s: %s [%s]" % (self._line_number, |
| 225 | line.strip(), |
| 226 | reason)) |
| 227 | |
| 228 | def TotalTests(self): |
| 229 | """Returns the number of parsed tests.""" |
| 230 | return len(self._test_status) |
| 231 | |
| 232 | def PassedTests(self): |
| 233 | """Returns list of tests that passed.""" |
| 234 | return self._TestsByStatus('OK', False, False) |
| 235 | |
| 236 | def FailedTests(self, include_fails=False, include_flaky=False): |
| 237 | """Returns list of tests that failed, timed out, or didn't finish. |
| 238 | |
| 239 | This list will be incorrect until the complete log has been processed, |
| 240 | because it will show currently running tests as having failed. |
| 241 | |
| 242 | Args: |
| 243 | include_fails: If true, all failing tests with FAILS_ in their |
| 244 | names will be included. Otherwise, they will only be included |
| 245 | if they crashed. |
| 246 | include_flaky: If true, all failing tests with FLAKY_ in their |
| 247 | names will be included. Otherwise, they will only be included |
| 248 | if they crashed. |
| 249 | Returns: |
| 250 | List of failed tests. |
| 251 | """ |
| 252 | return (self._TestsByStatus('failed', include_fails, include_flaky) + |
| 253 | self._TestsByStatus('timeout', include_fails, include_flaky)) |
| 254 | |
| 255 | def FailureDescription(self, test): |
| 256 | """Returns a list containing the failure description for the given test. |
| 257 | |
| 258 | If the test didn't fail or timeout, returns []. |
| 259 | Args: |
| 260 | test: Name to test to find failure reason. |
| 261 | Returns: |
| 262 | List of test name, and failure string. |
| 263 | """ |
| 264 | test_status = self._test_status.get(test, ('', [])) |
| 265 | return ["%s: " % test] + test_status[1] |
| 266 | |
| 267 | def SuppressionHashes(self): |
| 268 | """Returns list of suppression hashes found in the log.""" |
| 269 | return self._suppressions.keys() |
| 270 | |
| 271 | def Suppression(self, suppression_hash): |
| 272 | """Returns a list containing the suppression for a given hash. |
| 273 | |
| 274 | If the suppression hash doesn't exist, returns []. |
| 275 | |
| 276 | Args: |
| 277 | suppression_hash: name of hash. |
| 278 | Returns: |
| 279 | List of suppression for the hash. |
| 280 | """ |
| 281 | return self._suppressions.get(suppression_hash, []) |
| 282 | |
| 283 | def ProcessLogLine(self, line): |
| 284 | """This is called once with each line of the test log.""" |
| 285 | |
| 286 | # Track line number for error messages. |
| 287 | self._line_number += 1 |
| 288 | |
| 289 | if not self.master_name: |
| 290 | results = self._master_name_re.search(line) |
| 291 | if results: |
| 292 | self.master_name = results.group(1) |
| 293 | |
| 294 | # Is it a line reporting disabled tests? |
| 295 | results = self._disabled.search(line) |
| 296 | if results: |
| 297 | try: |
| 298 | disabled = int(results.group(1)) |
| 299 | except ValueError: |
| 300 | disabled = 0 |
| 301 | if disabled > 0 and isinstance(self.disabled_tests, int): |
| 302 | self.disabled_tests += disabled |
| 303 | else: |
| 304 | # If we can't parse the line, at least give a heads-up. This is |
| 305 | # a safety net for a case that shouldn't happen but isn't a |
| 306 | # fatal error. |
| 307 | self.disabled_tests = 'some' |
| 308 | return |
| 309 | |
| 310 | # Is it a line reporting flaky tests? |
| 311 | results = self._flaky.search(line) |
| 312 | if results: |
| 313 | try: |
| 314 | flaky = int(results.group(1)) |
| 315 | except ValueError: |
| 316 | flaky = 0 |
| 317 | if flaky > 0 and isinstance(self.flaky_tests, int): |
| 318 | self.flaky_tests = flaky |
| 319 | else: |
| 320 | # If we can't parse the line, at least give a heads-up. This is |
| 321 | # a safety net for a case that shouldn't happen but isn't a |
| 322 | # fatal error. |
| 323 | self.flaky_tests = 'some' |
| 324 | return |
| 325 | |
| 326 | # Is it the start of a test? |
| 327 | results = self._test_start.search(line) |
| 328 | if results: |
| 329 | test_name = results.group(1) |
| 330 | if test_name in self._test_status: |
| 331 | self._RecordError(line, 'test started more than once') |
| 332 | return |
| 333 | if self._current_test: |
| 334 | status = self._StatusOfTest(self._current_test) |
| 335 | if status in ('OK', 'failed', 'timeout'): |
| 336 | self._RecordError(line, 'start while in status %s' % status) |
| 337 | return |
| 338 | if status not in ('failed', 'timeout'): |
| 339 | self._test_status[self._current_test] = ( |
| 340 | 'failed', self._failure_description) |
| 341 | self._test_status[test_name] = ('started', ['Did not complete.']) |
| 342 | self._current_test = test_name |
| 343 | self._failure_description = [] |
| 344 | return |
| 345 | |
| 346 | # Is it a test success line? |
| 347 | results = self._test_ok.search(line) |
| 348 | if results: |
| 349 | test_name = results.group(1) |
| 350 | status = self._StatusOfTest(test_name) |
| 351 | if status != 'started': |
| 352 | self._RecordError(line, 'success while in status %s' % status) |
| 353 | return |
| 354 | self._test_status[test_name] = ('OK', []) |
| 355 | self._failure_description = [] |
| 356 | self._current_test = '' |
| 357 | return |
| 358 | |
| 359 | # Is it a test failure line? |
| 360 | results = self._test_fail.search(line) |
| 361 | if results: |
| 362 | test_name = results.group(1) |
| 363 | status = self._StatusOfTest(test_name) |
| 364 | if status not in ('started', 'failed', 'timeout'): |
| 365 | self._RecordError(line, 'failure while in status %s' % status) |
| 366 | return |
| 367 | # Don't overwrite the failure description when a failing test is |
| 368 | # listed a second time in the summary, or if it was already |
| 369 | # recorded as timing out. |
| 370 | if status not in ('failed', 'timeout'): |
| 371 | self._test_status[test_name] = ('failed', |
| 372 | self._failure_description) |
| 373 | self._failure_description = [] |
| 374 | self._current_test = '' |
| 375 | return |
| 376 | |
| 377 | # Is it a test timeout line? |
| 378 | results = self._test_timeout.search(line) |
| 379 | if results: |
| 380 | test_name = results.group(1) |
| 381 | status = self._StatusOfTest(test_name) |
| 382 | if status not in ('started', 'failed'): |
| 383 | self._RecordError(line, 'timeout while in status %s' % status) |
| 384 | return |
| 385 | self._test_status[test_name] = ( |
| 386 | 'timeout', self._failure_description + ['Killed (timed out).']) |
| 387 | self._failure_description = [] |
| 388 | self._current_test = '' |
| 389 | return |
| 390 | |
| 391 | # Is it the start of a new valgrind suppression? |
| 392 | results = self._suppression_start.search(line) |
| 393 | if results: |
| 394 | suppression_hash = results.group(1) |
| 395 | if suppression_hash in self._suppressions: |
| 396 | self._RecordError(line, 'suppression reported more than once') |
| 397 | return |
| 398 | self._suppressions[suppression_hash] = [] |
| 399 | self._current_suppression_hash = suppression_hash |
| 400 | self._current_suppression = [line] |
| 401 | return |
| 402 | |
| 403 | # Is it the end of a valgrind suppression? |
| 404 | results = self._suppression_end.search(line) |
| 405 | if results and self._current_suppression_hash: |
| 406 | self._current_suppression.append(line) |
| 407 | self._suppressions[self._current_suppression_hash] = ( |
| 408 | self._current_suppression) |
| 409 | self._current_suppression_hash = '' |
| 410 | self._current_suppression = [] |
| 411 | return |
| 412 | |
| 413 | # Random line: if we're in a suppression, collect it. Suppressions are |
| 414 | # generated after all tests are finished, so this should always belong |
| 415 | # to the current suppression hash. |
| 416 | if self._current_suppression_hash: |
| 417 | self._current_suppression.append(line) |
| 418 | return |
| 419 | |
| 420 | # Random line: if we're in a test, collect it for the failure |
| 421 | # description. Tests may run simultaneously, so this might be off, but |
| 422 | # it's worth a try. |
| 423 | if self._current_test: |
| 424 | self._failure_description.append(line) |