|
| 1 | +"""Tests for recovering gtest failure output from a job's console log.""" |
| 2 | + |
| 3 | +from __future__ import annotations |
| 4 | + |
| 5 | +from scripts.test_failure_detector.gtest_log_parser import ( |
| 6 | + parse_gtest_failures_from_log, |
| 7 | +) |
| 8 | + |
| 9 | +# A real block as GitHub stores it: an ISO timestamp on every line and gtest's |
| 10 | +# colour codes around its status tags. |
| 11 | +_REAL_LOG = ( |
| 12 | + "2026-07-29T23:29:31.7910142Z [6/298] DictTest.BasicOps (2 ms)\n" |
| 13 | + "2026-07-29T23:29:31.7912499Z \x1b[0;33mNote: Google Test filter = DictTest.BasicOps\n" |
| 14 | + "2026-07-29T23:29:31.7914000Z [==========] Running 1 test from 1 test suite.\n" |
| 15 | + "2026-07-29T23:29:31.7916184Z \x1b[0;32m[ RUN ] \x1b[mDictTest.BasicOps\n" |
| 16 | + "2026-07-29T23:29:31.7916693Z test_dict.cpp:34: Failure\n" |
| 17 | + "2026-07-29T23:29:31.7917000Z Expected equality of these values:\n" |
| 18 | + "2026-07-29T23:29:31.7917500Z got\n" |
| 19 | + "2026-07-29T23:29:31.7918000Z Which is: \"myvalue\"\n" |
| 20 | + "2026-07-29T23:29:31.7918500Z \"wrongvalue\"\n" |
| 21 | + "2026-07-29T23:29:31.7919098Z \x1b[0;31m[ FAILED ] \x1b[mDictTest.BasicOps (0 ms)\n" |
| 22 | + "2026-07-29T23:29:31.7923059Z 1 FAILED TEST\n" |
| 23 | + "2026-07-29T23:29:31.7923540Z [6/298] DictTest.BasicOps returned with exit code 1 (2 ms)\n" |
| 24 | +) |
| 25 | + |
| 26 | + |
| 27 | +class TestParseGtestFailuresFromLog: |
| 28 | + def test_recovers_the_failing_tests_output(self) -> None: |
| 29 | + out = parse_gtest_failures_from_log(_REAL_LOG.encode()) |
| 30 | + assert list(out) == ["DictTest.BasicOps"] |
| 31 | + body = out["DictTest.BasicOps"] |
| 32 | + assert "test_dict.cpp:34: Failure" in body |
| 33 | + assert "Expected equality of these values:" in body |
| 34 | + assert '"wrongvalue"' in body |
| 35 | + |
| 36 | + def test_strips_timestamps_and_colour_codes(self) -> None: |
| 37 | + """Both are log transport, not diagnostic. The timestamp also has to go |
| 38 | + or two runs of one failure would never compare equal in the recurrence |
| 39 | + check.""" |
| 40 | + body = parse_gtest_failures_from_log(_REAL_LOG.encode())["DictTest.BasicOps"] |
| 41 | + assert "2026-07-29T23:29:31" not in body |
| 42 | + assert "\x1b[" not in body |
| 43 | + assert "[ RUN ] DictTest.BasicOps" in body |
| 44 | + |
| 45 | + def test_a_passing_test_is_not_reported(self) -> None: |
| 46 | + """Only a block closed by a non-zero exit code is a failure, so a test |
| 47 | + that passed on a retry contributes nothing.""" |
| 48 | + log = ( |
| 49 | + "[1/2] DictTest.Passing (1 ms)\n" |
| 50 | + "[ OK ] DictTest.Passing\n" |
| 51 | + "[2/2] DictTest.Other (1 ms)\n" |
| 52 | + "[2/2] DictTest.Other returned with exit code 1 (1 ms)\n" |
| 53 | + ) |
| 54 | + assert list(parse_gtest_failures_from_log(log.encode())) == ["DictTest.Other"] |
| 55 | + |
| 56 | + def test_interleaved_blocks_stay_separate(self) -> None: |
| 57 | + """gtest-parallel runs tests concurrently, so one test's output can |
| 58 | + appear between another's start and end.""" |
| 59 | + log = ( |
| 60 | + "[1/2] SuiteA.One (1 ms)\n" |
| 61 | + "a-first-line\n" |
| 62 | + "[2/2] SuiteB.Two (1 ms)\n" |
| 63 | + "b-first-line\n" |
| 64 | + "[1/2] SuiteA.One returned with exit code 1 (1 ms)\n" |
| 65 | + "[2/2] SuiteB.Two returned with exit code 1 (1 ms)\n" |
| 66 | + ) |
| 67 | + out = parse_gtest_failures_from_log(log.encode()) |
| 68 | + assert set(out) == {"SuiteA.One", "SuiteB.Two"} |
| 69 | + assert "a-first-line" in out["SuiteA.One"] |
| 70 | + assert "b-first-line" in out["SuiteB.Two"] |
| 71 | + assert "b-first-line" not in out["SuiteA.One"] |
| 72 | + |
| 73 | + def test_a_log_with_no_gtest_blocks_yields_nothing(self) -> None: |
| 74 | + """The normal case for a job that runs no unit tests.""" |
| 75 | + assert parse_gtest_failures_from_log(b"make: *** [all] Error 1\n") == {} |
| 76 | + |
| 77 | + def test_an_unterminated_block_is_not_reported(self) -> None: |
| 78 | + """A job killed mid-test leaves an open block, which says nothing about |
| 79 | + whether the test failed.""" |
| 80 | + log = "[1/2] SuiteA.One (1 ms)\nsome output\n" |
| 81 | + assert parse_gtest_failures_from_log(log.encode()) == {} |
| 82 | + |
| 83 | + def test_a_runaway_block_is_capped(self) -> None: |
| 84 | + """A test that logs in a loop must not put thousands of lines in an |
| 85 | + issue body. The head holds the assertion.""" |
| 86 | + noise = "".join(f"line {i}\n" for i in range(5000)) |
| 87 | + log = ( |
| 88 | + "[1/1] SuiteA.One (1 ms)\n" |
| 89 | + f"{noise}" |
| 90 | + "[1/1] SuiteA.One returned with exit code 1 (1 ms)\n" |
| 91 | + ) |
| 92 | + body = parse_gtest_failures_from_log(log.encode())["SuiteA.One"] |
| 93 | + assert body.count("\n") < 250 |
| 94 | + assert "line 0" in body |
| 95 | + |
| 96 | + def test_invalid_utf8_does_not_raise(self) -> None: |
| 97 | + log = b"[1/1] SuiteA.One (1 ms)\n\xff\xfe bad bytes\n[1/1] SuiteA.One returned with exit code 1 (1 ms)\n" |
| 98 | + assert "SuiteA.One" in parse_gtest_failures_from_log(log) |
| 99 | + |
| 100 | + def test_a_retry_that_passed_is_not_reported(self) -> None: |
| 101 | + """gtest-parallel reruns a failed test and closes the retry with exit |
| 102 | + code 0. Only the non-zero close is a failure, so a test that passed on |
| 103 | + retry contributes no output.""" |
| 104 | + log = ( |
| 105 | + "[1/1] SuiteA.One (1 ms)\n" |
| 106 | + "first attempt failed\n" |
| 107 | + "[1/1] SuiteA.One returned with exit code 1 (1 ms)\n" |
| 108 | + "[1/1] SuiteA.One (1 ms)\n" |
| 109 | + "second attempt passed\n" |
| 110 | + "[1/1] SuiteA.One returned with exit code 0 (1 ms)\n" |
| 111 | + ) |
| 112 | + assert parse_gtest_failures_from_log(log.encode()) == {} |
0 commit comments