From 6000fa0e7855fea713bb28b11369159b6baba792 Mon Sep 17 00:00:00 2001 From: "anthony.wen" Date: Mon, 30 Mar 2026 16:07:20 -0400 Subject: [PATCH] Include failure detail in ATVM host status output --- atvm/watcher-service/atvm_run_watcher.py | 102 +++++++++++++++++++++-- 1 file changed, 96 insertions(+), 6 deletions(-) diff --git a/atvm/watcher-service/atvm_run_watcher.py b/atvm/watcher-service/atvm_run_watcher.py index eaffac4..343fd4b 100644 --- a/atvm/watcher-service/atvm_run_watcher.py +++ b/atvm/watcher-service/atvm_run_watcher.py @@ -349,7 +349,7 @@ def parse_host_xml(xml_path: Path) -> Optional[Tuple[str, HostResult]]: return None root = tree.getroot() suites = root.findall("testsuite") - best: Optional[Tuple[str, int, int, float, Optional[datetime]]] = None + best: Optional[Tuple[str, int, int, float, Optional[datetime], Optional[str]]] = None for suite in suites: file_attr = suite.attrib.get("file", "") suite_name = suite.attrib.get("name", "") @@ -367,17 +367,20 @@ def parse_host_xml(xml_path: Path) -> Optional[Tuple[str, HostResult]]: failures = int(float(suite.attrib.get("failures", root.attrib.get("failures", "0")))) total_time = float(suite.attrib.get("time", root.attrib.get("time", "0"))) timestamp = parse_xml_timestamp(suite.attrib.get("timestamp")) - candidate = (host_name, tests, failures, total_time, timestamp) + failure_detail = extract_failure_detail_from_xml_suite(suite) + candidate = (host_name, tests, failures, total_time, timestamp, failure_detail) if best is None: best = candidate continue - _, best_tests, _, best_total_time, _ = best + _, best_tests, _, best_total_time, _, _ = best if tests > best_tests or (tests == best_tests and total_time >= best_total_time): best = candidate if not best: return None - file_name, tests, failures, total_time, timestamp = best + file_name, tests, failures, total_time, timestamp, failure_detail = best detail = f"{tests} tests, {failures} failures" + if failures: + detail = append_failure_detail(detail, failure_detail) status = "FAIL" if failures else "PASS" return file_name, HostResult( host=file_name, @@ -423,6 +426,66 @@ def parse_duration_seconds(raw: str) -> Optional[float]: return hours * 3600 + minutes * 60 + seconds +def compact_failure_detail(raw: str, limit: int = 220) -> str: + normalized = " ".join(raw.split()) + if len(normalized) <= limit: + return normalized + return normalized[: limit - 3].rstrip() + "..." + + +def append_failure_detail(detail: str, failure_detail: Optional[str]) -> str: + if not failure_detail or failure_detail in detail: + return detail + return f"{detail} - {failure_detail}" + + +def extract_failure_detail_from_xml_suite(suite: ET.Element) -> Optional[str]: + for testcase in suite.findall("testcase"): + failure = testcase.find("failure") + if failure is None: + continue + case_name = testcase.attrib.get("classname") or testcase.attrib.get("name") or "failed testcase" + parts = [] + message = failure.attrib.get("message") + if message: + parts.append(message) + if failure.text: + parts.append(failure.text) + failure_text = compact_failure_detail(" ".join(parts)) if parts else "" + if failure_text: + return compact_failure_detail(f"{case_name}: {failure_text}") + return compact_failure_detail(case_name) + return None + + +def extract_failure_detail_from_text_blob(text: str) -> Optional[str]: + for pattern in ( + r"(Error:\s.*?)(?:\n\s*\n|$)", + r"(AssertionError:\s.*?)(?:\n\s*\n|$)", + r"(Timed out!.*?)(?:\n\s*\n|$)", + ): + match = re.search(pattern, text, re.I | re.S) + if match: + return compact_failure_detail(match.group(1)) + return None + + +def extract_failure_detail_from_log_text(log_text: str, host: str) -> Optional[str]: + pattern = ( + rf"\d+\)\s+Testing .*?{re.escape(host)}.*?\n" + rf"\s+([^\n]+):\n" + rf"\s+(.*?)(?:\n\s*\n|\n\s* Dict[str, HostResult]: host_results: Dict[str, HostResult] = {} normalized = re.sub(r"\n\s*│\s*s\s*│", "s", segment_text) @@ -463,13 +526,17 @@ def extract_completed_subrun_summaries(log_text: str, inventory: Dict[str, str]) for block in cloud_blocks: block_text = block.group(1) currents_url = block.group(2) + prior_segment = log_text[previous_block_end:block.start()] + detail_source = prior_segment + "\n" + block_text host_results = extract_host_results_from_run_finished_segment(block_text, inventory) if not host_results: - prior_segment = log_text[previous_block_end:block.start()] host_results = extract_host_results_from_run_finished_segment(prior_segment, inventory) if not host_results: previous_block_end = block.end() continue + for host, result in host_results.items(): + if result.failures: + result.detail = append_failure_detail(result.detail, extract_failure_detail_from_log_text(detail_source, host)) summaries.append( { "host_results": host_results, @@ -631,6 +698,23 @@ def parse_host_reporter_json(artifact_path: Path, host: str, kernels: Dict[str, if pending: detail_parts.append(f"{pending} pending") + failure_detail = None + tests_payload = payload.get("tests") + if failures and isinstance(tests_payload, dict): + for testcase_name, testcase_events in tests_payload.items(): + if not isinstance(testcase_name, str) or not isinstance(testcase_events, list): + continue + for event in testcase_events: + if not isinstance(event, list) or len(event) < 3: + continue + status_value = str(event[2]).lower() + message_value = str(event[1]) if len(event) > 1 else "" + if status_value in {"failed", "fail", "error"} or re.search(r"\b(Error|AssertionError|Timed out!)\b", message_value): + failure_detail = compact_failure_detail(f"{testcase_name} - {message_value}") + break + if failure_detail: + break + if failures: status = "FAIL" elif pending: @@ -638,11 +722,15 @@ def parse_host_reporter_json(artifact_path: Path, host: str, kernels: Dict[str, else: status = "PASS" + detail = ", ".join(detail_parts) + if failures: + detail = append_failure_detail(detail, failure_detail) + return HostResult( host=host, kernel=kernels.get(host, "unknown"), status=status, - detail=", ".join(detail_parts), + detail=detail, tests=tests, failures=failures, duration_seconds=float(duration_ms) / 1000 if duration_ms else None, @@ -666,6 +754,8 @@ def parse_host_reporter_artifact(artifact_path: Path, host: str, kernels: Dict[s failures = 1 if re.search(r"\bTimed out!\b|\bAssertionError\b|\bFAIL\b|\berror\b", text, re.I) else 0 status = "FAIL" if failures else "PASS" detail = "1 failures" if failures else "completed" + if failures: + detail = append_failure_detail(detail, extract_failure_detail_from_text_blob(text)) return HostResult( host=host, kernel=kernels.get(host, "unknown"),