Skip to content

Commit fd1e5a0

Browse files
BChan-0vsajan
authored andcommitted
Attach gtest console output to unit-test failures
Signed-off-by: Bonnie Chan <bonniecv@amazon.com>
1 parent 22c53f4 commit fd1e5a0

4 files changed

Lines changed: 344 additions & 7 deletions

File tree

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
"""Recover gtest failure output from a job's console log.
2+
3+
gtest-parallel's JSON dump records only a per-test verdict (PASS, FAIL,
4+
TIMEOUT) and timings, so the extraction action can report no more than which
5+
test failed. The assertion that failed, its file and line, and the expected and
6+
actual values are printed to the console instead. This reads that block back out
7+
of the job log so an issue carries the diagnostic a maintainer needs.
8+
"""
9+
10+
from __future__ import annotations
11+
12+
import logging
13+
import re
14+
15+
logger = logging.getLogger(__name__)
16+
17+
# gtest-parallel brackets each test it runs with a progress line, and closes a
18+
# failing one by repeating the header with the exit code:
19+
#
20+
# [6/298] DummyTest.IntentionalFailure (2 ms)
21+
# ... the test's own gtest output ...
22+
# [6/298] DummyTest.IntentionalFailure returned with exit code 1 (2 ms)
23+
#
24+
# Anchoring on the closing line rather than the next test's header keeps the
25+
# block intact when tests run in parallel and their output interleaves.
26+
_GTEST_BLOCK_START_RE = re.compile(
27+
r"^\[\d+/\d+\]\s+(?P<test>[\w./:]+(?:\.[\w./:]+)?)\s+\([\d.]+\s*m?s\)\s*$"
28+
)
29+
_GTEST_BLOCK_END_RE = re.compile(
30+
r"^\[\d+/\d+\]\s+(?P<test>[\w./:]+)\s+returned with exit code\s+(?P<code>\d+)"
31+
)
32+
33+
# A GitHub Actions log line is prefixed with an ISO timestamp, and gtest colours
34+
# its output. Both are noise in an issue body, and the timestamp would also make
35+
# two runs of one failure compare unequal in the recurrence check.
36+
_LOG_TIMESTAMP_RE = re.compile(r"^\d{4}-\d{2}-\d{2}T[\d:.]+Z\s?")
37+
_ANSI_RE = re.compile(r"\033\[[0-9;]*m")
38+
39+
# A block longer than this is not a test diagnostic: a test that logs in a loop
40+
# can print thousands of lines before failing. The head holds the assertion, so
41+
# the cap keeps that and drops the rest.
42+
_MAX_BLOCK_LINES = 200
43+
44+
45+
def _clean(line: str) -> str:
46+
return _ANSI_RE.sub("", _LOG_TIMESTAMP_RE.sub("", line)).rstrip()
47+
48+
49+
def parse_gtest_failures_from_log(log_content: bytes) -> dict[str, str]:
50+
"""Map test name to its console output, for tests that failed in *log*.
51+
52+
Only tests whose block is closed by a non-zero exit code are returned, so a
53+
test that passed on a retry contributes nothing. Returns an empty mapping
54+
when the log holds no gtest blocks, which is the normal case for a job that
55+
runs no unit tests.
56+
"""
57+
try:
58+
text = log_content.decode("utf-8", errors="replace")
59+
except Exception:
60+
logger.warning("Could not decode a job log while recovering gtest output")
61+
return {}
62+
63+
failures: dict[str, str] = {}
64+
# gtest-parallel buffers each test's output and prints it in one piece
65+
# between that test's own progress lines, so an output line belongs to the
66+
# most recently opened block. Appending to every open block instead would
67+
# copy one test's output into another's issue.
68+
open_blocks: dict[str, list[str]] = {}
69+
current: str | None = None
70+
for raw_line in text.split("\n"):
71+
line = _clean(raw_line)
72+
73+
end = _GTEST_BLOCK_END_RE.match(line)
74+
if end is not None:
75+
test = end.group("test")
76+
body = open_blocks.pop(test, None)
77+
if end.group("code") == "0":
78+
# The attempt passed. gtest-parallel reruns a failed test, so a
79+
# zero close can follow a non-zero one for the same test; the
80+
# retry passed and the run does not treat it as a failure, so
81+
# any output recorded for the earlier attempt is discarded.
82+
failures.pop(test, None)
83+
elif body is not None:
84+
failures[test] = "\n".join([*body, line]).strip()
85+
if current == test:
86+
current = None
87+
continue
88+
89+
start = _GTEST_BLOCK_START_RE.match(line)
90+
if start is not None:
91+
current = start.group("test")
92+
open_blocks[current] = [line]
93+
continue
94+
95+
if current is None:
96+
continue
97+
body = open_blocks[current]
98+
if len(body) < _MAX_BLOCK_LINES:
99+
body.append(line)
100+
101+
if failures:
102+
logger.info(
103+
"Recovered console output for %d gtest failure(s)", len(failures)
104+
)
105+
return failures

scripts/test_failure_detector/timeout_recovery.py

Lines changed: 32 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,9 @@
55
identifies failed jobs without a captured timeout, downloads their console logs,
66
and extracts [TIMEOUT] failures that would otherwise be invisible.
77
8-
It also attaches the runner's clients-state report to timeouts, whose artifact
9-
entries carry only a verdict, for the same reason: the diagnostic exists only
10-
in the log.
8+
It also fills in failures whose artifact entry carries only a verdict, for the
9+
same reason: a timeout's clients-state report and a gtest failure's assertion
10+
output both exist only in the log.
1111
1212
Orchestration is separated from parsing: :mod:`timeout_parser` and
1313
:mod:`gtest_log_parser` handle the regex extraction; this module decides which
@@ -22,6 +22,9 @@
2222

2323
from scripts.common.workflow_artifacts import ArtifactClient
2424
from scripts.test_failure_detector.download import JobInfo
25+
from scripts.test_failure_detector.gtest_log_parser import (
26+
parse_gtest_failures_from_log,
27+
)
2528
from scripts.test_failure_detector.parse_failures import FailureType, UniqueFailure
2629
from scripts.test_failure_detector.timeout_parser import (
2730
clients_state_report_from_log,
@@ -127,9 +130,11 @@ def enrich_log_only_errors(
127130
) -> None:
128131
"""Attach console output to failures the artifact records without detail.
129132
133+
Two types arrive with a placeholder instead of a diagnostic. A gtest failure
134+
carries only a verdict, because gtest-parallel's JSON dump holds no output.
130135
A timeout carries only "Test timed out", because the runner has nothing to
131-
report beyond the watchdog firing. The detail exists in the job log, so it
132-
is read back and attached in place.
136+
report beyond the watchdog firing. In both cases the detail exists in the
137+
job log, so it is read back and attached in place.
133138
134139
Best-effort, and mutates *failures* in place. A log that is expired,
135140
unavailable, or missing the failure's block leaves the placeholder as it
@@ -138,18 +143,29 @@ def enrich_log_only_errors(
138143
Shares its ``run_logs`` with timeout recovery, so the run's log zip is
139144
downloaded at most once per run.
140145
"""
146+
# The extraction action attaches gtest-parallel's per-test log when it finds
147+
# one and falls back to a bare verdict when it does not. Only the fallback is
148+
# enriched: the action's own text comes from the test's dedicated log file,
149+
# while the run log interleaves every worker's output, so overwriting it
150+
# replaced a full assertion with the two progress lines around it.
151+
gtest_failures = [
152+
f for f in failures
153+
if f.failure_type == FailureType.UNITTEST
154+
and f.test_name
155+
and "\n" not in f.error.strip()
156+
]
141157
# A timeout recovered from a log already carries its report; only the
142158
# artifact-derived ones still hold the bare placeholder.
143159
timeout_failures = [
144160
f for f in failures
145161
if f.failure_type == FailureType.TIMEOUT and "\n" not in f.error.strip()
146162
]
147-
if not timeout_failures:
163+
if not gtest_failures and not timeout_failures:
148164
return
149165

150166
jobs_to_scan = {
151167
job_ref.job
152-
for f in timeout_failures
168+
for f in (*gtest_failures, *timeout_failures)
153169
for job_ref in f.jobs
154170
} & set(job_info.failed)
155171
if not jobs_to_scan:
@@ -162,16 +178,25 @@ def enrich_log_only_errors(
162178

163179
# One failure can appear on several jobs. Scan in a stable order so the
164180
# attached output does not depend on set iteration order.
181+
gtest_outputs: dict[str, str] = {}
165182
timeout_reports: dict[str, str] = {}
166183
for job_name in sorted(jobs_to_scan):
167184
log_content = find_job_log(logs, job_name)
168185
if log_content is None:
169186
continue
187+
for test_name, body in parse_gtest_failures_from_log(log_content).items():
188+
gtest_outputs.setdefault(test_name, body)
170189
report = clients_state_report_from_log(log_content)
171190
if report:
172191
timeout_reports.setdefault(job_name, report)
173192

174193
enriched = 0
194+
for failure in gtest_failures:
195+
recovered_output = gtest_outputs.get(failure.test_name)
196+
if recovered_output:
197+
failure.error = recovered_output
198+
enriched += 1
199+
175200
for failure in timeout_failures:
176201
for job_ref in failure.jobs:
177202
job_report = timeout_reports.get(job_ref.job)
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
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

Comments
 (0)