Skip to content

Commit f902c7a

Browse files
author
Nils Bars
committed
Add failure log management for E2E tests
- Clear failure_logs directory at session start to remove stale logs - Generate SUMMARY.txt with categorized errors after test failures - Add summarize_logs.py utility for parsing and categorizing test failures - Document test log summary usage in CLAUDE.md
1 parent 3b5e17f commit f902c7a

3 files changed

Lines changed: 239 additions & 0 deletions

File tree

.claude/CLAUDE.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -202,3 +202,15 @@ These approaches hide the underlying problem rather than fixing it. Race conditi
202202

203203
- Do not include Claude as author or co-author in commit messages.
204204
- Do not include historical context like "this fixes the failing test" or "this addresses the previous issue". Describe what the change does, not why it was needed.
205+
206+
## Test Log Summary
207+
208+
After test failures, a summary is automatically generated at `tests/failure_logs/SUMMARY.txt`. To regenerate manually:
209+
210+
```bash
211+
cd tests && python3 summarize_logs.py
212+
```
213+
214+
**Maintaining the pattern list:** The `ERROR_PATTERNS` dict in `tests/summarize_logs.py` defines which errors are detected. Keep this list accurate:
215+
- **Add patterns** for error types that appear in logs but are missing from the summary
216+
- **Remove patterns** that trigger false positives (matching non-error text)

tests/conftest.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@
4545
REFInstanceManager,
4646
cleanup_docker_resources_by_prefix,
4747
)
48+
from summarize_logs import generate_summary # noqa: E402
4849
from test_config import generate_test_prefix # noqa: E402
4950

5051
# =============================================================================
@@ -941,6 +942,14 @@ def pytest_sessionstart(session: Session) -> None:
941942
except Exception as e:
942943
print(f"[REF E2E] Warning: Failed to remove {coverage_file.name}: {e}")
943944

945+
# Clean up failure logs from previous test runs
946+
if FAILURE_LOG_DIR.exists():
947+
try:
948+
shutil.rmtree(FAILURE_LOG_DIR)
949+
print("[REF E2E] Cleared failure logs from previous run")
950+
except Exception as e:
951+
print(f"[REF E2E] Warning: Failed to clear failure logs: {e}")
952+
944953

945954
def pytest_sessionfinish(session: Session, exitstatus: int) -> None:
946955
"""
@@ -952,6 +961,16 @@ def pytest_sessionfinish(session: Session, exitstatus: int) -> None:
952961
print("\n[Coverage] Combining all coverage data...")
953962
combine_all_coverage()
954963

964+
# Generate failure log summary if there were any failures
965+
if exitstatus != 0:
966+
failure_logs_dir = Path(__file__).parent / "failure_logs"
967+
if failure_logs_dir.exists() and any(failure_logs_dir.iterdir()):
968+
print("\n[REF E2E] Generating failure log summary...")
969+
summary = generate_summary(failure_logs_dir)
970+
output_path = failure_logs_dir / "SUMMARY.txt"
971+
output_path.write_text(summary)
972+
print(f"[REF E2E] Summary written to: {output_path}")
973+
955974
# Final cleanup pass for resources
956975
if os.environ.get("REF_CLEANUP_ON_EXIT", "1") == "1":
957976
# Clean up all session's resources (safety net if fixture cleanup failed)

tests/summarize_logs.py

Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
1+
#!/usr/bin/env python3
2+
"""
3+
Summarize test failure logs by scanning for common error patterns.
4+
5+
Usage:
6+
cd tests && python summarize_logs.py
7+
8+
Output is written to tests/failure_logs/SUMMARY.txt
9+
"""
10+
11+
import re
12+
from collections import defaultdict
13+
from datetime import datetime
14+
from pathlib import Path
15+
16+
# Error patterns to detect. Each key is a label, value is a regex pattern.
17+
# Maintain this dict:
18+
# - Add patterns for error types that appear in logs but are missing from summaries
19+
# - Remove patterns that trigger false positives (matching non-error text)
20+
ERROR_PATTERNS: dict[str, str] = {
21+
# Python built-in exceptions
22+
"TypeError": r"TypeError:",
23+
"ValueError": r"ValueError:",
24+
"KeyError": r"KeyError:",
25+
"IndexError": r"IndexError:",
26+
"AttributeError": r"AttributeError:",
27+
"NameError": r"NameError:",
28+
"ImportError": r"ImportError:",
29+
"ModuleNotFoundError": r"ModuleNotFoundError:",
30+
"RuntimeError": r"RuntimeError:",
31+
"AssertionError": r"AssertionError:",
32+
"TimeoutError": r"TimeoutError:",
33+
"OSError": r"OSError:",
34+
"FileNotFoundError": r"FileNotFoundError:",
35+
"PermissionError": r"PermissionError:",
36+
"ConnectionError": r"ConnectionError:",
37+
"ConnectionRefusedError": r"ConnectionRefusedError:",
38+
"BrokenPipeError": r"BrokenPipeError:",
39+
"TimeoutExpired": r"TimeoutExpired:",
40+
"CalledProcessError": r"CalledProcessError:",
41+
# Custom exceptions from REF codebase
42+
"InconsistentStateError": r"InconsistentStateError:",
43+
"RemoteExecutionError": r"RemoteExecutionError:",
44+
"ApiRequestError": r"ApiRequestError:",
45+
"SSHException": r"SSHException:",
46+
# Rust/SSH-Proxy patterns
47+
"[SSH-PROXY] error": r"\[SSH-PROXY\].*(?:[Ee]rror|[Ff]ailed)",
48+
"Rust panic": r"thread '.*' panicked",
49+
# Generic patterns
50+
"Traceback": r"Traceback \(most recent call last\)",
51+
"Connection refused": r"Connection refused",
52+
"HTTP 4xx": r"HTTP[/ ]4\d{2}|status[_ ]code[=: ]+4\d{2}",
53+
"HTTP 5xx": r"HTTP[/ ]5\d{2}|status[_ ]code[=: ]+5\d{2}",
54+
}
55+
56+
# Log files to scan within each failure directory
57+
LOG_FILES = ["error.txt", "container_logs.txt", "app.log", "build.log"]
58+
59+
60+
def scan_file(file_path: Path) -> list[tuple[str, int, str]]:
61+
"""
62+
Scan a file for error patterns.
63+
64+
Returns list of (error_label, line_number, matched_line) tuples.
65+
"""
66+
matches: list[tuple[str, int, str]] = []
67+
68+
if not file_path.exists():
69+
return matches
70+
71+
try:
72+
content = file_path.read_text(errors="replace")
73+
except Exception:
74+
return matches
75+
76+
lines = content.splitlines()
77+
compiled_patterns = {
78+
label: re.compile(pattern) for label, pattern in ERROR_PATTERNS.items()
79+
}
80+
81+
for line_num, line in enumerate(lines, start=1):
82+
for label, regex in compiled_patterns.items():
83+
if regex.search(line):
84+
matches.append((label, line_num, line.strip()[:100]))
85+
86+
return matches
87+
88+
89+
def scan_failure_dir(failure_dir: Path) -> dict[str, list[tuple[str, int]]]:
90+
"""
91+
Scan all log files in a failure directory.
92+
93+
Returns dict mapping error label to list of (log_file, line_num) tuples.
94+
"""
95+
results: dict[str, list[tuple[str, int]]] = defaultdict(list)
96+
97+
for log_file in LOG_FILES:
98+
file_path = failure_dir / log_file
99+
matches = scan_file(file_path)
100+
for label, line_num, _ in matches:
101+
results[label].append((log_file, line_num))
102+
103+
return dict(results)
104+
105+
106+
def generate_summary(failure_logs_dir: Path) -> str:
107+
"""Generate the full summary text."""
108+
# Collect all failure directories
109+
failure_dirs = sorted(
110+
[d for d in failure_logs_dir.iterdir() if d.is_dir()],
111+
key=lambda x: x.name,
112+
)
113+
114+
if not failure_dirs:
115+
return "No failure directories found.\n"
116+
117+
# Data structures for both sections
118+
# by_error_type[label] = [(dir_name, file, line), ...]
119+
by_error_type: dict[str, list[tuple[str, str, int]]] = defaultdict(list)
120+
# by_test[dir_name] = [(label, file, line), ...]
121+
by_test: dict[str, list[tuple[str, str, int]]] = defaultdict(list)
122+
123+
for failure_dir in failure_dirs:
124+
dir_name = failure_dir.name
125+
results = scan_failure_dir(failure_dir)
126+
127+
for label, file_line_pairs in results.items():
128+
for log_file, line_num in file_line_pairs:
129+
by_error_type[label].append((dir_name, log_file, line_num))
130+
by_test[dir_name].append((label, log_file, line_num))
131+
132+
# Build summary text
133+
lines: list[str] = []
134+
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M")
135+
136+
lines.append("=== Test Failure Log Summary ===")
137+
lines.append(f"Generated: {timestamp}")
138+
lines.append(f"Scanned: {len(failure_dirs)} failure directories")
139+
lines.append("")
140+
141+
# Section 1: By Error Type
142+
lines.append("=" * 80)
143+
lines.append("SECTION 1: BY ERROR TYPE")
144+
lines.append("=" * 80)
145+
lines.append("")
146+
147+
if by_error_type:
148+
for label in sorted(by_error_type.keys()):
149+
occurrences = by_error_type[label]
150+
lines.append(f"{label} ({len(occurrences)} occurrences):")
151+
for dir_name, log_file, line_num in occurrences[
152+
:20
153+
]: # Limit to 20 per type
154+
lines.append(f" {dir_name}/{log_file}:{line_num}")
155+
if len(occurrences) > 20:
156+
lines.append(f" ... and {len(occurrences) - 20} more")
157+
lines.append("")
158+
else:
159+
lines.append("No error patterns detected.")
160+
lines.append("")
161+
162+
# Section 2: By Test
163+
lines.append("=" * 80)
164+
lines.append("SECTION 2: BY TEST")
165+
lines.append("=" * 80)
166+
lines.append("")
167+
168+
if by_test:
169+
for dir_name in sorted(by_test.keys()):
170+
errors = by_test[dir_name]
171+
lines.append(f"{dir_name}/:")
172+
# Deduplicate and show unique error types per file
173+
seen: set[tuple[str, str, int]] = set()
174+
for label, log_file, line_num in errors:
175+
key = (label, log_file, line_num)
176+
if key not in seen:
177+
seen.add(key)
178+
lines.append(f" {label} @ {log_file}:{line_num}")
179+
lines.append("")
180+
else:
181+
lines.append("No test failures with detected errors.")
182+
lines.append("")
183+
184+
return "\n".join(lines)
185+
186+
187+
def main() -> None:
188+
script_dir = Path(__file__).parent
189+
failure_logs_dir = script_dir / "failure_logs"
190+
191+
if not failure_logs_dir.exists():
192+
print(f"Failure logs directory not found: {failure_logs_dir}")
193+
return
194+
195+
summary = generate_summary(failure_logs_dir)
196+
197+
# Write to SUMMARY.txt
198+
output_path = failure_logs_dir / "SUMMARY.txt"
199+
output_path.write_text(summary)
200+
print(f"Summary written to: {output_path}")
201+
202+
# Also print to stdout
203+
print()
204+
print(summary)
205+
206+
207+
if __name__ == "__main__":
208+
main()

0 commit comments

Comments
 (0)