Lloyd Pique | ee8a2b9 | 2017-09-27 17:14:32 -0700 | [diff] [blame] | 1 | # Copyright (c) 2017 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 class to parse the output of a gtest suite run.""" |
| 6 | |
| 7 | import re |
| 8 | |
| 9 | |
| 10 | class gtest_parser(object): |
| 11 | """This class knows how to understand GTest test output. |
| 12 | |
| 13 | The code was borrowed with minor changes from chrome utility gtest_command. |
| 14 | http://src.chromium.org/viewvc/chrome/trunk/tools/build/scripts/master/ |
| 15 | log_parser/gtest_command.py?view=markup |
| 16 | """ |
| 17 | |
| 18 | def __init__(self): |
| 19 | # State tracking for log parsing |
| 20 | self._current_test = '' |
| 21 | self._failure_description = [] |
| 22 | self._current_suppression_hash = '' |
| 23 | self._current_suppression = [] |
| 24 | |
| 25 | # Line number currently being processed. |
| 26 | self._line_number = 0 |
| 27 | |
| 28 | # List of parsing errors, as human-readable strings. |
| 29 | self.internal_error_lines = [] |
| 30 | |
| 31 | # Tests are stored here as 'test.name': (status, [description]). |
| 32 | # The status should be one of ('started', 'OK', 'failed', 'timeout'). |
| 33 | # The description is a list of lines detailing the test's error, as |
| 34 | # reported in the log. |
| 35 | self._test_status = {} |
| 36 | |
| 37 | # Suppressions are stored here as 'hash': [suppression]. |
| 38 | self._suppressions = {} |
| 39 | |
| 40 | # This may be either text or a number. It will be used in the phrase |
| 41 | # '%s disabled' or '%s flaky' on the waterfall display. |
| 42 | self.disabled_tests = 0 |
| 43 | self.flaky_tests = 0 |
| 44 | |
| 45 | # Regular expressions for parsing GTest logs. Test names look like |
| 46 | # SomeTestCase.SomeTest |
| 47 | # SomeName/SomeTestCase.SomeTest/1 |
| 48 | # This regexp also matches SomeName.SomeTest/1, which should be |
| 49 | # harmless. |
| 50 | test_name_regexp = r'((\w+/)?\w+\.\w+(\.\w+)?(/\d+)?)' |
| 51 | self._test_start = re.compile('\[\s+RUN\s+\] ' + test_name_regexp) |
| 52 | self._test_ok = re.compile('\[\s+OK\s+\] ' + test_name_regexp) |
| 53 | self._test_fail = re.compile('\[\s+FAILED\s+\] ' + test_name_regexp) |
| 54 | self._test_timeout = re.compile( |
| 55 | 'Test timeout \([0-9]+ ms\) exceeded for ' + test_name_regexp) |
| 56 | self._disabled = re.compile(' YOU HAVE (\d+) DISABLED TEST') |
| 57 | self._flaky = re.compile(' YOU HAVE (\d+) FLAKY TEST') |
| 58 | |
| 59 | self._suppression_start = re.compile( |
| 60 | 'Suppression \(error hash=#([0-9A-F]+)#\):') |
| 61 | self._suppression_end = re.compile('^}\s*$') |
| 62 | |
| 63 | self._master_name_re = re.compile('\[Running for master: "([^"]*)"') |
| 64 | self.master_name = '' |
| 65 | |
| 66 | self._error_logging_start_re = re.compile('=' * 70) |
| 67 | self._error_logging_test_name_re = re.compile( |
| 68 | '[FAIL|ERROR]: ' + test_name_regexp) |
| 69 | self._error_logging_end_re = re.compile('-' * 70) |
| 70 | self._error_logging_first_dash_found = False |
| 71 | |
| 72 | def _TestsByStatus(self, status, include_fails, include_flaky): |
| 73 | """Returns list of tests with the given status. |
| 74 | |
| 75 | Args: |
| 76 | status: test results status to search for. |
| 77 | include_fails: If False, tests containing 'FAILS_' anywhere in |
| 78 | their names will be excluded from the list. |
| 79 | include_flaky: If False, tests containing 'FLAKY_' anywhere in |
| 80 | their names will be excluded from the list. |
| 81 | Returns: |
| 82 | List of tests with the status. |
| 83 | """ |
| 84 | test_list = [x[0] for x in self._test_status.items() |
| 85 | if self._StatusOfTest(x[0]) == status] |
| 86 | |
| 87 | if not include_fails: |
| 88 | test_list = [x for x in test_list if x.find('FAILS_') == -1] |
| 89 | if not include_flaky: |
| 90 | test_list = [x for x in test_list if x.find('FLAKY_') == -1] |
| 91 | |
| 92 | return test_list |
| 93 | |
| 94 | def _StatusOfTest(self, test): |
| 95 | """Returns the status code for the given test, or 'not known'.""" |
| 96 | test_status = self._test_status.get(test, ('not known', [])) |
| 97 | return test_status[0] |
| 98 | |
| 99 | def _RecordError(self, line, reason): |
| 100 | """Record a log line that produced a parsing error. |
| 101 | |
| 102 | Args: |
| 103 | line: text of the line at which the error occurred. |
| 104 | reason: a string describing the error. |
| 105 | """ |
| 106 | self.internal_error_lines.append("%s: %s [%s]" % (self._line_number, |
| 107 | line.strip(), |
| 108 | reason)) |
| 109 | |
| 110 | def TotalTests(self): |
| 111 | """Returns the number of parsed tests.""" |
| 112 | return len(self._test_status) |
| 113 | |
| 114 | def PassedTests(self): |
| 115 | """Returns list of tests that passed.""" |
| 116 | return self._TestsByStatus('OK', False, False) |
| 117 | |
| 118 | def FailedTests(self, include_fails=False, include_flaky=False): |
| 119 | """Returns list of tests that failed, timed out, or didn't finish. |
| 120 | |
| 121 | This list will be incorrect until the complete log has been processed, |
| 122 | because it will show currently running tests as having failed. |
| 123 | |
| 124 | Args: |
| 125 | include_fails: If true, all failing tests with FAILS_ in their |
| 126 | names will be included. Otherwise, they will only be included |
| 127 | if they crashed. |
| 128 | include_flaky: If true, all failing tests with FLAKY_ in their |
| 129 | names will be included. Otherwise, they will only be included |
| 130 | if they crashed. |
| 131 | Returns: |
| 132 | List of failed tests. |
| 133 | """ |
| 134 | return (self._TestsByStatus('failed', include_fails, include_flaky) + |
| 135 | self._TestsByStatus('timeout', include_fails, include_flaky) + |
| 136 | self._TestsByStatus('started', include_fails, include_flaky)) |
| 137 | |
| 138 | def FailureDescription(self, test): |
| 139 | """Returns a list containing the failure description for the given test. |
| 140 | |
| 141 | If the test didn't fail or timeout, returns []. |
| 142 | Args: |
| 143 | test: Name to test to find failure reason. |
| 144 | Returns: |
| 145 | List of test name, and failure string. |
| 146 | """ |
| 147 | test_status = self._test_status.get(test, ('', [])) |
| 148 | return test_status[1] |
| 149 | |
| 150 | def SuppressionHashes(self): |
| 151 | """Returns list of suppression hashes found in the log.""" |
| 152 | return self._suppressions.keys() |
| 153 | |
| 154 | def Suppression(self, suppression_hash): |
| 155 | """Returns a list containing the suppression for a given hash. |
| 156 | |
| 157 | If the suppression hash doesn't exist, returns []. |
| 158 | |
| 159 | Args: |
| 160 | suppression_hash: name of hash. |
| 161 | Returns: |
| 162 | List of suppression for the hash. |
| 163 | """ |
| 164 | return self._suppressions.get(suppression_hash, []) |
| 165 | |
| 166 | def ProcessLogLine(self, line): |
| 167 | """This is called once with each line of the test log.""" |
| 168 | |
| 169 | # Track line number for error messages. |
| 170 | self._line_number += 1 |
| 171 | |
| 172 | if not self.master_name: |
| 173 | results = self._master_name_re.search(line) |
| 174 | if results: |
| 175 | self.master_name = results.group(1) |
| 176 | |
| 177 | # Is it a line reporting disabled tests? |
| 178 | results = self._disabled.search(line) |
| 179 | if results: |
| 180 | try: |
| 181 | disabled = int(results.group(1)) |
| 182 | except ValueError: |
| 183 | disabled = 0 |
| 184 | if disabled > 0 and isinstance(self.disabled_tests, int): |
| 185 | self.disabled_tests += disabled |
| 186 | else: |
| 187 | # If we can't parse the line, at least give a heads-up. This is |
| 188 | # a safety net for a case that shouldn't happen but isn't a |
| 189 | # fatal error. |
| 190 | self.disabled_tests = 'some' |
| 191 | return |
| 192 | |
| 193 | # Is it a line reporting flaky tests? |
| 194 | results = self._flaky.search(line) |
| 195 | if results: |
| 196 | try: |
| 197 | flaky = int(results.group(1)) |
| 198 | except ValueError: |
| 199 | flaky = 0 |
| 200 | if flaky > 0 and isinstance(self.flaky_tests, int): |
| 201 | self.flaky_tests = flaky |
| 202 | else: |
| 203 | # If we can't parse the line, at least give a heads-up. This is |
| 204 | # a safety net for a case that shouldn't happen but isn't a |
| 205 | # fatal error. |
| 206 | self.flaky_tests = 'some' |
| 207 | return |
| 208 | |
| 209 | # Is it the start of a test? |
| 210 | results = self._test_start.search(line) |
| 211 | if results: |
| 212 | test_name = results.group(1) |
| 213 | if test_name in self._test_status: |
| 214 | self._RecordError(line, 'test started more than once') |
| 215 | return |
| 216 | if self._current_test: |
| 217 | status = self._StatusOfTest(self._current_test) |
| 218 | if status in ('OK', 'failed', 'timeout'): |
| 219 | self._RecordError(line, 'start while in status %s' % status) |
| 220 | return |
| 221 | if status not in ('failed', 'timeout'): |
| 222 | self._test_status[self._current_test] = ( |
| 223 | 'failed', self._failure_description) |
| 224 | self._test_status[test_name] = ('started', ['Did not complete.']) |
| 225 | self._current_test = test_name |
| 226 | self._failure_description = [] |
| 227 | return |
| 228 | |
| 229 | # Is it a test success line? |
| 230 | results = self._test_ok.search(line) |
| 231 | if results: |
| 232 | test_name = results.group(1) |
| 233 | status = self._StatusOfTest(test_name) |
| 234 | if status != 'started': |
| 235 | self._RecordError(line, 'success while in status %s' % status) |
| 236 | return |
| 237 | self._test_status[test_name] = ('OK', []) |
| 238 | self._failure_description = [] |
| 239 | self._current_test = '' |
| 240 | return |
| 241 | |
| 242 | # Is it a test failure line? |
| 243 | results = self._test_fail.search(line) |
| 244 | if results: |
| 245 | test_name = results.group(1) |
| 246 | status = self._StatusOfTest(test_name) |
| 247 | if status not in ('started', 'failed', 'timeout'): |
| 248 | self._RecordError(line, 'failure while in status %s' % status) |
| 249 | return |
| 250 | # Don't overwrite the failure description when a failing test is |
| 251 | # listed a second time in the summary, or if it was already |
| 252 | # recorded as timing out. |
| 253 | if status not in ('failed', 'timeout'): |
| 254 | self._test_status[test_name] = ('failed', |
| 255 | self._failure_description) |
| 256 | self._failure_description = [] |
| 257 | self._current_test = '' |
| 258 | return |
| 259 | |
| 260 | # Is it a test timeout line? |
| 261 | results = self._test_timeout.search(line) |
| 262 | if results: |
| 263 | test_name = results.group(1) |
| 264 | status = self._StatusOfTest(test_name) |
| 265 | if status not in ('started', 'failed'): |
| 266 | self._RecordError(line, 'timeout while in status %s' % status) |
| 267 | return |
| 268 | self._test_status[test_name] = ( |
| 269 | 'timeout', self._failure_description + ['Killed (timed out).']) |
| 270 | self._failure_description = [] |
| 271 | self._current_test = '' |
| 272 | return |
| 273 | |
| 274 | # Is it the start of a new valgrind suppression? |
| 275 | results = self._suppression_start.search(line) |
| 276 | if results: |
| 277 | suppression_hash = results.group(1) |
| 278 | if suppression_hash in self._suppressions: |
| 279 | self._RecordError(line, 'suppression reported more than once') |
| 280 | return |
| 281 | self._suppressions[suppression_hash] = [] |
| 282 | self._current_suppression_hash = suppression_hash |
| 283 | self._current_suppression = [line] |
| 284 | return |
| 285 | |
| 286 | # Is it the end of a valgrind suppression? |
| 287 | results = self._suppression_end.search(line) |
| 288 | if results and self._current_suppression_hash: |
| 289 | self._current_suppression.append(line) |
| 290 | self._suppressions[self._current_suppression_hash] = ( |
| 291 | self._current_suppression) |
| 292 | self._current_suppression_hash = '' |
| 293 | self._current_suppression = [] |
| 294 | return |
| 295 | |
| 296 | # Is it the start of a test summary error message? |
| 297 | results = self._error_logging_test_name_re.search(line) |
| 298 | if results: |
| 299 | test_name = results.group(1) |
| 300 | self._test_status[test_name] = ('failed', ['Output not found.']) |
| 301 | self._current_test = test_name |
| 302 | self._failure_description = [] |
| 303 | self._error_logging_first_dash_found = False |
| 304 | return |
| 305 | |
| 306 | # Is it the start of the next test summary signaling the end |
| 307 | # of the previous message? |
| 308 | results = self._error_logging_start_re.search(line) |
| 309 | if results and self._current_test: |
| 310 | self._test_status[self._current_test] = ('failed', |
| 311 | self._failure_description) |
| 312 | self._failure_description = [] |
| 313 | self._current_test = '' |
| 314 | return |
| 315 | |
| 316 | # Is it the end of the extra test failure summaries? |
| 317 | results = self._error_logging_end_re.search(line) |
| 318 | if results and self._current_test: |
| 319 | if self._error_logging_first_dash_found: |
| 320 | self._test_status[self._current_test] = ( |
| 321 | 'failed', self._failure_description) |
| 322 | self._failure_description = [] |
| 323 | self._current_test = '' |
| 324 | self._error_logging_first_dash_found = True |
| 325 | return |
| 326 | |
| 327 | # Random line: if we're in a suppression, collect it. Suppressions are |
| 328 | # generated after all tests are finished, so this should always belong |
| 329 | # to the current suppression hash. |
| 330 | if self._current_suppression_hash: |
| 331 | self._current_suppression.append(line) |
| 332 | return |
| 333 | |
| 334 | # Random line: if we're in a test, collect it for the failure |
| 335 | # description. Tests may run simultaneously, so this might be off, but |
| 336 | # it's worth a try. |
| 337 | if self._current_test: |
| 338 | self._failure_description.append(line) |