|
| 1 | +#!/usr/bin/env python3 |
| 2 | +""" |
| 3 | +Streaming filter for Gradle test output. |
| 4 | +
|
| 5 | +Compresses verbose test logs: |
| 6 | + - PASSED tests: single summary line; all buffered output (including [TEST::INFO]) discarded |
| 7 | + - FAILED tests: full context emitted: STARTED line + buffered stdout/stderr + FAILED line |
| 8 | + followed by exception/stack trace that comes after the FAILED marker |
| 9 | + - SKIPPED tests: single summary line |
| 10 | + - CRASHED tests: if the stream ends mid-test (JVM kill, OOM, sanitizer abort), the full |
| 11 | + buffer is emitted with a warning header |
| 12 | +
|
| 13 | +Designed for inline use with `tee` so the unfiltered raw log is preserved: |
| 14 | +
|
| 15 | + ./gradlew ... 2>&1 \\ |
| 16 | + | tee -a "${RAW_LOG}" \\ |
| 17 | + | python3 -u .github/scripts/filter_gradle_log.py |
| 18 | +
|
| 19 | +Exit code and PIPESTATUS: |
| 20 | + The filter always exits 0 regardless of test outcomes; use ${PIPESTATUS[0]} in bash |
| 21 | + to capture the Gradle exit code: |
| 22 | +
|
| 23 | + ./gradlew ... 2>&1 | tee -a raw.log | python3 -u filter_gradle_log.py |
| 24 | + GRADLE_EXIT=${PIPESTATUS[0]} |
| 25 | +
|
| 26 | +Limitations: |
| 27 | + - [TEST::INFO] lines emitted from class-level lifecycle methods (@BeforeAll, static |
| 28 | + initializers) appear before any STARTED marker and are suppressed in OUTSIDE state. |
| 29 | + They remain visible in the raw log preserved by tee. |
| 30 | +""" |
| 31 | + |
| 32 | +import re |
| 33 | +import sys |
| 34 | + |
| 35 | +# Matches Gradle per-test event lines emitted by the Test task: |
| 36 | +# |
| 37 | +# com.example.FooTest > testBar STARTED |
| 38 | +# com.example.FooTest > testBar[1] PASSED (0.456s) |
| 39 | +# com.example.FooTest > testBar(int) FAILED |
| 40 | +# com.example.FooTest > testBar SKIPPED |
| 41 | +# |
| 42 | +# The class name starts with a word character (not '>'), which prevents matching |
| 43 | +# "> Task :project:taskName FAILED" build-level lines. |
| 44 | +_TEST_EVENT = re.compile( |
| 45 | + r'^([\w.$][\w.$ ]* > \S.*?) (STARTED|PASSED|FAILED|SKIPPED)(\s+\([^)]+\))?\s*$' |
| 46 | +) |
| 47 | + |
| 48 | + |
| 49 | +def emit(line: str) -> None: |
| 50 | + print(line, flush=True) |
| 51 | + |
| 52 | + |
| 53 | +def main() -> None: |
| 54 | + # --- States --- |
| 55 | + OUTSIDE = 0 # between tests: pass lines through directly |
| 56 | + BUFFERING = 1 # inside a running test: accumulate output |
| 57 | + FAILING = 2 # after FAILED marker: pass lines through until next test |
| 58 | + |
| 59 | + state = OUTSIDE |
| 60 | + buf: list = [] |
| 61 | + |
| 62 | + for raw in sys.stdin: |
| 63 | + line = raw.rstrip('\n') |
| 64 | + m = _TEST_EVENT.match(line) |
| 65 | + |
| 66 | + if m: |
| 67 | + event = m.group(2) |
| 68 | + |
| 69 | + if event == 'STARTED': |
| 70 | + if state == BUFFERING: |
| 71 | + # Previous test had no outcome line (shouldn't normally happen). |
| 72 | + # Emit the buffer so we don't silently discard output. |
| 73 | + for buffered_line in buf: |
| 74 | + emit(buffered_line) |
| 75 | + elif state == FAILING: |
| 76 | + emit('') # blank line to visually separate failure blocks |
| 77 | + |
| 78 | + # Include the STARTED line in the buffer so it appears in failure output. |
| 79 | + buf = [line] |
| 80 | + state = BUFFERING |
| 81 | + |
| 82 | + elif event == 'PASSED': |
| 83 | + buf = [] |
| 84 | + emit(line) |
| 85 | + state = OUTSIDE |
| 86 | + |
| 87 | + elif event == 'FAILED': |
| 88 | + # Emit everything collected since STARTED (includes [TEST::INFO] lines). |
| 89 | + for buffered_line in buf: |
| 90 | + emit(buffered_line) |
| 91 | + buf = [] |
| 92 | + emit(line) |
| 93 | + state = FAILING |
| 94 | + |
| 95 | + elif event == 'SKIPPED': |
| 96 | + buf = [] |
| 97 | + emit(line) |
| 98 | + state = OUTSIDE |
| 99 | + |
| 100 | + elif state == BUFFERING: |
| 101 | + buf.append(line) |
| 102 | + |
| 103 | + else: |
| 104 | + # OUTSIDE or FAILING: pass through directly. |
| 105 | + # In FAILING state this captures exception lines, stack traces, etc. |
| 106 | + # In OUTSIDE state, suppress [TEST::INFO] lines: they originate from |
| 107 | + # class-level init (@BeforeAll, static blocks) and are noise when no |
| 108 | + # test has failed; the raw log still contains them for reference. |
| 109 | + if state == FAILING or not line.startswith('[TEST::INFO]'): |
| 110 | + emit(line) |
| 111 | + |
| 112 | + # EOF handling: if still inside a test the JVM likely crashed (SIGABRT from sanitizer, |
| 113 | + # OOM kill, etc.). Emit everything so the failure is visible in the filtered log. |
| 114 | + if state == BUFFERING and buf: |
| 115 | + emit('# WARNING: stream ended inside a test (crash / OOM / sanitizer abort?)') |
| 116 | + for buffered_line in buf: |
| 117 | + emit(buffered_line) |
| 118 | + |
| 119 | + |
| 120 | +if __name__ == '__main__': |
| 121 | + main() |
0 commit comments