Chromium Code Reviews
chromiumcodereview-hr@appspot.gserviceaccount.com (chromiumcodereview-hr) | Please choose your nickname with Settings | Help | Chromium Project | Gerrit Changes | Sign out
(218)

Side by Side Diff: infra/scripts/legacy/scripts/common/gtest_utils.py

Issue 1213433006: Fork runtest.py and everything it needs src-side for easier hacking (Closed) Base URL: https://chromium.googlesource.com/chromium/src.git@master
Patch Set: runisolatedtest.py Created 5 years, 5 months ago
Use n/p to move between diff chunks; N/P to move between comments. Draft comments are only viewable by you.
Jump to:
View unified diff | Download patch
OLDNEW
(Empty)
1 #!/usr/bin/env python
2 # Copyright (c) 2012 The Chromium 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 from common import chromium_utils
7 import json
8 import os
9 import re
10 import tempfile
11
12
13 # These labels should match the ones output by gtest's JSON.
14 TEST_UNKNOWN_LABEL = 'UNKNOWN'
15 TEST_SUCCESS_LABEL = 'SUCCESS'
16 TEST_FAILURE_LABEL = 'FAILURE'
17 TEST_FAILURE_ON_EXIT_LABEL = 'FAILURE_ON_EXIT'
18 TEST_CRASH_LABEL = 'CRASH'
19 TEST_TIMEOUT_LABEL = 'TIMEOUT'
20 TEST_SKIPPED_LABEL = 'SKIPPED'
21 TEST_WARNING_LABEL = 'WARNING'
22
23 FULL_RESULTS_FILENAME = 'full_results.json'
24 TIMES_MS_FILENAME = 'times_ms.json'
25
26 def CompressList(lines, max_length, middle_replacement):
27 """Ensures that |lines| is no longer than |max_length|. If |lines| need to
28 be compressed then the middle items are replaced by |middle_replacement|.
29 """
30 if len(lines) <= max_length:
31 return lines
32 remove_from_start = max_length / 2
33 return (lines[:remove_from_start] +
34 [middle_replacement] +
35 lines[len(lines) - (max_length - remove_from_start):])
36
37
38 class GTestLogParser(object):
39 """This helper class process GTest test output."""
40
41 def __init__(self):
42 # State tracking for log parsing
43 self.completed = False
44 self._current_test = ''
45 self._failure_description = []
46 self._current_report_hash = ''
47 self._current_report = []
48 self._parsing_failures = False
49
50 # Line number currently being processed.
51 self._line_number = 0
52
53 # List of parsing errors, as human-readable strings.
54 self._internal_error_lines = []
55
56 # Tests are stored here as 'test.name': (status, [description]).
57 # The status should be one of ('started', 'OK', 'failed', 'timeout',
58 # 'warning'). Warning indicates that a test did not pass when run in
59 # parallel with other tests but passed when run alone. The description is
60 # a list of lines detailing the test's error, as reported in the log.
61 self._test_status = {}
62
63 # Reports are stored here as 'hash': [report].
64 self._memory_tool_reports = {}
65
66 # This may be either text or a number. It will be used in the phrase
67 # '%s disabled' or '%s flaky' on the waterfall display.
68 self._disabled_tests = 0
69 self._flaky_tests = 0
70
71 # Regular expressions for parsing GTest logs. Test names look like
72 # SomeTestCase.SomeTest
73 # SomeName/SomeTestCase.SomeTest/1
74 # This regexp also matches SomeName.SomeTest/1, which should be harmless.
75 test_name_regexp = r'((\w+/)?\w+\.\w+(/\d+)?)'
76
77 self._master_name_re = re.compile(r'\[Running for master: "([^"]*)"')
78 self.master_name = ''
79
80 self._test_name = re.compile(test_name_regexp)
81 self._test_start = re.compile(r'\[\s+RUN\s+\] ' + test_name_regexp)
82 self._test_ok = re.compile(r'\[\s+OK\s+\] ' + test_name_regexp)
83 self._test_fail = re.compile(r'\[\s+FAILED\s+\] ' + test_name_regexp)
84 self._test_passed = re.compile(r'\[\s+PASSED\s+\] \d+ tests?.')
85 self._run_test_cases_line = re.compile(
86 r'\[\s*\d+\/\d+\]\s+[0-9\.]+s ' + test_name_regexp + ' .+')
87 self._test_timeout = re.compile(
88 r'Test timeout \([0-9]+ ms\) exceeded for ' + test_name_regexp)
89 self._disabled = re.compile(r'\s*YOU HAVE (\d+) DISABLED TEST')
90 self._flaky = re.compile(r'\s*YOU HAVE (\d+) FLAKY TEST')
91
92 self._report_start = re.compile(
93 r'### BEGIN MEMORY TOOL REPORT \(error hash=#([0-9A-F]+)#\)')
94 self._report_end = re.compile(
95 r'### END MEMORY TOOL REPORT \(error hash=#([0-9A-F]+)#\)')
96
97 self._retry_message = re.compile('RETRYING FAILED TESTS:')
98 self.retrying_failed = False
99
100 self.TEST_STATUS_MAP = {
101 'OK': TEST_SUCCESS_LABEL,
102 'failed': TEST_FAILURE_LABEL,
103 'timeout': TEST_TIMEOUT_LABEL,
104 'warning': TEST_WARNING_LABEL
105 }
106
107 def GetCurrentTest(self):
108 return self._current_test
109
110 def _StatusOfTest(self, test):
111 """Returns the status code for the given test, or 'not known'."""
112 test_status = self._test_status.get(test, ('not known', []))
113 return test_status[0]
114
115 def _TestsByStatus(self, status, include_fails, include_flaky):
116 """Returns list of tests with the given status.
117
118 Args:
119 include_fails: If False, tests containing 'FAILS_' anywhere in their
120 names will be excluded from the list.
121 include_flaky: If False, tests containing 'FLAKY_' anywhere in their
122 names will be excluded from the list.
123 """
124 test_list = [x[0] for x in self._test_status.items()
125 if self._StatusOfTest(x[0]) == status]
126
127 if not include_fails:
128 test_list = [x for x in test_list if x.find('FAILS_') == -1]
129 if not include_flaky:
130 test_list = [x for x in test_list if x.find('FLAKY_') == -1]
131
132 return test_list
133
134 def _RecordError(self, line, reason):
135 """Record a log line that produced a parsing error.
136
137 Args:
138 line: text of the line at which the error occurred
139 reason: a string describing the error
140 """
141 self._internal_error_lines.append('%s: %s [%s]' %
142 (self._line_number, line.strip(), reason))
143
144 def RunningTests(self):
145 """Returns list of tests that appear to be currently running."""
146 return self._TestsByStatus('started', True, True)
147
148 def ParsingErrors(self):
149 """Returns a list of lines that have caused parsing errors."""
150 return self._internal_error_lines
151
152 def ClearParsingErrors(self):
153 """Clears the currently stored parsing errors."""
154 self._internal_error_lines = ['Cleared.']
155
156 def PassedTests(self, include_fails=False, include_flaky=False):
157 """Returns list of tests that passed."""
158 return self._TestsByStatus('OK', include_fails, include_flaky)
159
160 def FailedTests(self, include_fails=False, include_flaky=False):
161 """Returns list of tests that failed, timed out, or didn't finish
162 (crashed).
163
164 This list will be incorrect until the complete log has been processed,
165 because it will show currently running tests as having failed.
166
167 Args:
168 include_fails: If true, all failing tests with FAILS_ in their names will
169 be included. Otherwise, they will only be included if they crashed or
170 timed out.
171 include_flaky: If true, all failing tests with FLAKY_ in their names will
172 be included. Otherwise, they will only be included if they crashed or
173 timed out.
174
175 """
176 return (self._TestsByStatus('failed', include_fails, include_flaky) +
177 self._TestsByStatus('timeout', True, True) +
178 self._TestsByStatus('warning', include_fails, include_flaky) +
179 self.RunningTests())
180
181 def TriesForTest(self, test):
182 """Returns a list containing the state for all tries of the given test.
183 This parser doesn't support retries so a single result is returned."""
184 return [self.TEST_STATUS_MAP.get(self._StatusOfTest(test),
185 TEST_UNKNOWN_LABEL)]
186
187 def DisabledTests(self):
188 """Returns the name of the disabled test (if there is only 1) or the number
189 of disabled tests.
190 """
191 return self._disabled_tests
192
193 def FlakyTests(self):
194 """Returns the name of the flaky test (if there is only 1) or the number
195 of flaky tests.
196 """
197 return self._flaky_tests
198
199 def FailureDescription(self, test):
200 """Returns a list containing the failure description for the given test.
201
202 If the test didn't fail or timeout, returns [].
203 """
204 test_status = self._test_status.get(test, ('', []))
205 return ['%s: ' % test] + test_status[1]
206
207 def MemoryToolReportHashes(self):
208 """Returns list of report hashes found in the log."""
209 return self._memory_tool_reports.keys()
210
211 def MemoryToolReport(self, report_hash):
212 """Returns a list containing the report for a given hash.
213
214 If the report hash doesn't exist, returns [].
215 """
216 return self._memory_tool_reports.get(report_hash, [])
217
218 def CompletedWithoutFailure(self):
219 """Returns True if all tests completed and no tests failed unexpectedly."""
220 return self.completed and not self.FailedTests()
221
222 def ProcessLine(self, line):
223 """This is called once with each line of the test log."""
224
225 # Track line number for error messages.
226 self._line_number += 1
227
228 # Some tests (net_unittests in particular) run subprocesses which can write
229 # stuff to shared stdout buffer. Sometimes such output appears between new
230 # line and gtest directives ('[ RUN ]', etc) which breaks the parser.
231 # Code below tries to detect such cases and recognize a mixed line as two
232 # separate lines.
233
234 # List of regexps that parses expects to find at the start of a line but
235 # which can be somewhere in the middle.
236 gtest_regexps = [
237 self._test_start,
238 self._test_ok,
239 self._test_fail,
240 self._test_passed,
241 ]
242
243 for regexp in gtest_regexps:
244 match = regexp.search(line)
245 if match:
246 break
247
248 if not match or match.start() == 0:
249 self._ProcessLine(line)
250 else:
251 self._ProcessLine(line[:match.start()])
252 self._ProcessLine(line[match.start():])
253
254 def _ProcessLine(self, line):
255 """Parses the line and changes the state of parsed tests accordingly.
256
257 Will recognize newly started tests, OK or FAILED statuses, timeouts, etc.
258 """
259
260 # Note: When sharding, the number of disabled and flaky tests will be read
261 # multiple times, so this will only show the most recent values (but they
262 # should all be the same anyway).
263
264 # Is it a line listing the master name?
265 if not self.master_name:
266 results = self._master_name_re.match(line)
267 if results:
268 self.master_name = results.group(1)
269
270 results = self._run_test_cases_line.match(line)
271 if results:
272 # A run_test_cases.py output.
273 if self._current_test:
274 if self._test_status[self._current_test][0] == 'started':
275 self._test_status[self._current_test] = (
276 'timeout', self._failure_description)
277 self._current_test = ''
278 self._failure_description = []
279 return
280
281 # Is it a line declaring all tests passed?
282 results = self._test_passed.match(line)
283 if results:
284 self.completed = True
285 self._current_test = ''
286 return
287
288 # Is it a line reporting disabled tests?
289 results = self._disabled.match(line)
290 if results:
291 try:
292 disabled = int(results.group(1))
293 except ValueError:
294 disabled = 0
295 if disabled > 0 and isinstance(self._disabled_tests, int):
296 self._disabled_tests = disabled
297 else:
298 # If we can't parse the line, at least give a heads-up. This is a
299 # safety net for a case that shouldn't happen but isn't a fatal error.
300 self._disabled_tests = 'some'
301 return
302
303 # Is it a line reporting flaky tests?
304 results = self._flaky.match(line)
305 if results:
306 try:
307 flaky = int(results.group(1))
308 except ValueError:
309 flaky = 0
310 if flaky > 0 and isinstance(self._flaky_tests, int):
311 self._flaky_tests = flaky
312 else:
313 # If we can't parse the line, at least give a heads-up. This is a
314 # safety net for a case that shouldn't happen but isn't a fatal error.
315 self._flaky_tests = 'some'
316 return
317
318 # Is it the start of a test?
319 results = self._test_start.match(line)
320 if results:
321 if self._current_test:
322 if self._test_status[self._current_test][0] == 'started':
323 self._test_status[self._current_test] = (
324 'timeout', self._failure_description)
325 test_name = results.group(1)
326 self._test_status[test_name] = ('started', ['Did not complete.'])
327 self._current_test = test_name
328 if self.retrying_failed:
329 self._failure_description = self._test_status[test_name][1]
330 self._failure_description.extend(['', 'RETRY OUTPUT:', ''])
331 else:
332 self._failure_description = []
333 return
334
335 # Is it a test success line?
336 results = self._test_ok.match(line)
337 if results:
338 test_name = results.group(1)
339 status = self._StatusOfTest(test_name)
340 if status != 'started':
341 self._RecordError(line, 'success while in status %s' % status)
342 if self.retrying_failed:
343 self._test_status[test_name] = ('warning', self._failure_description)
344 else:
345 self._test_status[test_name] = ('OK', [])
346 self._failure_description = []
347 self._current_test = ''
348 return
349
350 # Is it a test failure line?
351 results = self._test_fail.match(line)
352 if results:
353 test_name = results.group(1)
354 status = self._StatusOfTest(test_name)
355 if status not in ('started', 'failed', 'timeout'):
356 self._RecordError(line, 'failure while in status %s' % status)
357 # Don't overwrite the failure description when a failing test is listed a
358 # second time in the summary, or if it was already recorded as timing
359 # out.
360 if status not in ('failed', 'timeout'):
361 self._test_status[test_name] = ('failed', self._failure_description)
362 self._failure_description = []
363 self._current_test = ''
364 return
365
366 # Is it a test timeout line?
367 results = self._test_timeout.search(line)
368 if results:
369 test_name = results.group(1)
370 status = self._StatusOfTest(test_name)
371 if status not in ('started', 'failed'):
372 self._RecordError(line, 'timeout while in status %s' % status)
373 self._test_status[test_name] = (
374 'timeout', self._failure_description + ['Killed (timed out).'])
375 self._failure_description = []
376 self._current_test = ''
377 return
378
379 # Is it the start of a new memory tool report?
380 results = self._report_start.match(line)
381 if results:
382 report_hash = results.group(1)
383 if report_hash in self._memory_tool_reports:
384 self._RecordError(line, 'multiple reports for this hash')
385 self._memory_tool_reports[report_hash] = []
386 self._current_report_hash = report_hash
387 self._current_report = []
388 return
389
390 # Is it the end of a memory tool report?
391 results = self._report_end.match(line)
392 if results:
393 report_hash = results.group(1)
394 if not self._current_report_hash:
395 self._RecordError(line, 'no BEGIN matches this END')
396 elif report_hash != self._current_report_hash:
397 self._RecordError(line, 'expected (error hash=#%s#)' %
398 self._current_report_hash)
399 else:
400 self._memory_tool_reports[self._current_report_hash] = (
401 self._current_report)
402 self._current_report_hash = ''
403 self._current_report = []
404 return
405
406 # Is it the start of the retry tests?
407 results = self._retry_message.match(line)
408 if results:
409 self.retrying_failed = True
410 return
411
412 # Random line: if we're in a report, collect it. Reports are
413 # generated after all tests are finished, so this should always belong to
414 # the current report hash.
415 if self._current_report_hash:
416 self._current_report.append(line)
417 return
418
419 # Random line: if we're in a test, collect it for the failure description.
420 # Tests may run simultaneously, so this might be off, but it's worth a try.
421 # This also won't work if a test times out before it begins running.
422 if self._current_test:
423 self._failure_description.append(line)
424
425 # Parse the "Failing tests:" list at the end of the output, and add any
426 # additional failed tests to the list. For example, this includes tests
427 # that crash after the OK line.
428 if self._parsing_failures:
429 results = self._test_name.match(line)
430 if results:
431 test_name = results.group(1)
432 status = self._StatusOfTest(test_name)
433 if status in ('not known', 'OK'):
434 self._test_status[test_name] = (
435 'failed', ['Unknown error, see stdio log.'])
436 else:
437 self._parsing_failures = False
438 elif line.startswith('Failing tests:'):
439 self._parsing_failures = True
440
441
442 class GTestJSONParser(object):
443 # Limit of output snippet lines. Avoids flooding the logs with amount
444 # of output that gums up the infrastructure.
445 OUTPUT_SNIPPET_LINES_LIMIT = 5000
446
447 def __init__(self, mastername=None):
448 self.json_file_path = None
449 self.delete_json_file = False
450
451 self.disabled_tests = set()
452 self.passed_tests = set()
453 self.failed_tests = set()
454 self.flaky_tests = set()
455 self.test_logs = {}
456 self.run_results = {}
457 self.ignored_failed_tests = set()
458
459 self.parsing_errors = []
460
461 self.master_name = mastername
462
463 # List our labels that match the ones output by gtest JSON.
464 self.SUPPORTED_LABELS = (TEST_UNKNOWN_LABEL,
465 TEST_SUCCESS_LABEL,
466 TEST_FAILURE_LABEL,
467 TEST_FAILURE_ON_EXIT_LABEL,
468 TEST_CRASH_LABEL,
469 TEST_TIMEOUT_LABEL,
470 TEST_SKIPPED_LABEL)
471
472 def ProcessLine(self, line):
473 # Deliberately do nothing - we parse out-of-band JSON summary
474 # instead of in-band stdout.
475 pass
476
477 def PassedTests(self):
478 return sorted(self.passed_tests)
479
480 def FailedTests(self, include_fails=False, include_flaky=False):
481 return sorted(self.failed_tests - self.ignored_failed_tests)
482
483 def TriesForTest(self, test):
484 """Returns a list containing the state for all tries of the given test."""
485 return self.run_results.get(test, [TEST_UNKNOWN_LABEL])
486
487 def FailureDescription(self, test):
488 return self.test_logs.get(test, [])
489
490 def IgnoredFailedTests(self):
491 return sorted(self.ignored_failed_tests)
492
493 @staticmethod
494 def MemoryToolReportHashes():
495 return []
496
497 def ParsingErrors(self):
498 return self.parsing_errors
499
500 def ClearParsingErrors(self):
501 self.parsing_errors = ['Cleared.']
502
503 def DisabledTests(self):
504 return len(self.disabled_tests)
505
506 def FlakyTests(self):
507 return len(self.flaky_tests)
508
509 @staticmethod
510 def RunningTests():
511 return []
512
513 def PrepareJSONFile(self, cmdline_path):
514 if cmdline_path:
515 self.json_file_path = cmdline_path
516 # If the caller requested JSON summary, do not delete it.
517 self.delete_json_file = False
518 else:
519 fd, self.json_file_path = tempfile.mkstemp()
520 os.close(fd)
521 # When we create the file ourselves, delete it to avoid littering.
522 self.delete_json_file = True
523 return self.json_file_path
524
525 def ProcessJSONFile(self, build_dir):
526 if not self.json_file_path:
527 return
528
529 with open(self.json_file_path) as json_file:
530 try:
531 json_output = json_file.read()
532 json_data = json.loads(json_output)
533 except ValueError:
534 # Only signal parsing error if the file is non-empty. Empty file
535 # most likely means the binary doesn't support JSON output.
536 if json_output:
537 self.parsing_errors = json_output.split('\n')
538 else:
539 self.ProcessJSONData(json_data, build_dir)
540
541 if self.delete_json_file:
542 os.remove(self.json_file_path)
543
544 @staticmethod
545 def ParseIgnoredFailedTestSpec(dir_in_chrome):
546 """Returns parsed ignored failed test spec.
547
548 Args:
549 dir_in_chrome: Any directory within chrome checkout to be used as a
550 reference to find ignored failed test spec file.
551
552 Returns:
553 A list of tuples (test_name, platforms), where platforms is a list of sets
554 of platform flags. For example:
555
556 [('MyTest.TestOne', [set('OS_WIN', 'CPU_32_BITS', 'MODE_RELEASE'),
557 set('OS_LINUX', 'CPU_64_BITS', 'MODE_DEBUG')]),
558 ('MyTest.TestTwo', [set('OS_MACOSX', 'CPU_64_BITS', 'MODE_RELEASE'),
559 set('CPU_32_BITS')]),
560 ('MyTest.TestThree', [set()]]
561 """
562
563 try:
564 ignored_failed_tests_path = chromium_utils.FindUpward(
565 os.path.abspath(dir_in_chrome), 'tools', 'ignorer_bot',
566 'ignored_failed_tests.txt')
567 except chromium_utils.PathNotFound:
568 return
569
570 with open(ignored_failed_tests_path) as ignored_failed_tests_file:
571 ignored_failed_tests_spec = ignored_failed_tests_file.readlines()
572
573 parsed_spec = []
574 for spec_line in ignored_failed_tests_spec:
575 spec_line = spec_line.strip()
576 if spec_line.startswith('#') or not spec_line:
577 continue
578
579 # Any number of platform flags identifiers separated by whitespace.
580 platform_spec_regexp = r'[A-Za-z0-9_\s]*'
581
582 match = re.match(
583 r'^crbug.com/\d+' # Issue URL.
584 r'\s+' # Some whitespace.
585 r'\[(' + # Opening square bracket '['.
586 platform_spec_regexp + # At least one platform, and...
587 r'(?:,' + # ...separated by commas...
588 platform_spec_regexp + # ...any number of additional...
589 r')*' # ...platforms.
590 r')\]' # Closing square bracket ']'.
591 r'\s+' # Some whitespace.
592 r'(\S+)$', spec_line) # Test name.
593
594 if not match:
595 continue
596
597 platform_specs = match.group(1).strip()
598 test_name = match.group(2).strip()
599
600 platforms = [set(platform.split())
601 for platform in platform_specs.split(',')]
602
603 parsed_spec.append((test_name, platforms))
604
605 return parsed_spec
606
607
608 def _RetrieveIgnoredFailuresForPlatform(self, build_dir, platform_flags):
609 """Parses the ignored failed tests spec into self.ignored_failed_tests."""
610 if not build_dir:
611 return
612
613 platform_flags = set(platform_flags)
614 parsed_spec = self.ParseIgnoredFailedTestSpec(build_dir)
615
616 if not parsed_spec:
617 return
618
619 for test_name, platforms in parsed_spec:
620 for required_platform_flags in platforms:
621 if required_platform_flags.issubset(platform_flags):
622 self.ignored_failed_tests.add(test_name)
623 break
624
625 def ProcessJSONData(self, json_data, build_dir=None):
626 self.disabled_tests.update(json_data['disabled_tests'])
627 self._RetrieveIgnoredFailuresForPlatform(
628 build_dir, json_data['global_tags'])
629
630 for iteration_data in json_data['per_iteration_data']:
631 for test_name, test_runs in iteration_data.iteritems():
632 if test_runs[-1]['status'] == 'SUCCESS':
633 self.passed_tests.add(test_name)
634 else:
635 self.failed_tests.add(test_name)
636
637 self.run_results[test_name] = []
638 self.test_logs.setdefault(test_name, [])
639 for run_index, run_data in enumerate(test_runs, start=1):
640 # Mark as flaky if the run result differs.
641 if run_data['status'] != test_runs[0]['status']:
642 self.flaky_tests.add(test_name)
643 if run_data['status'] in self.SUPPORTED_LABELS:
644 self.run_results[test_name].append(run_data['status'])
645 else:
646 self.run_results[test_name].append(TEST_UNKNOWN_LABEL)
647 run_lines = ['%s (run #%d):' % (test_name, run_index)]
648 # Make sure the annotations are ASCII to avoid character set related
649 # errors. They are mostly informational anyway, and more detailed
650 # info can be obtained from the original JSON output.
651 ascii_lines = run_data['output_snippet'].encode('ascii',
652 errors='replace')
653 decoded_lines = CompressList(
654 ascii_lines.decode('string_escape').split('\n'),
655 self.OUTPUT_SNIPPET_LINES_LIMIT,
656 '<truncated, full output is in gzipped JSON '
657 'output at end of step>')
658 run_lines.extend(decoded_lines)
659 self.test_logs[test_name].extend(run_lines)
OLDNEW
« no previous file with comments | « infra/scripts/legacy/scripts/common/env.py ('k') | infra/scripts/legacy/scripts/common/url_helper.py » ('j') | no next file with comments »

Powered by Google App Engine
This is Rietveld 408576698