Include failure detail in ATVM host status output
This commit is contained in:
@@ -349,7 +349,7 @@ def parse_host_xml(xml_path: Path) -> Optional[Tuple[str, HostResult]]:
|
|||||||
return None
|
return None
|
||||||
root = tree.getroot()
|
root = tree.getroot()
|
||||||
suites = root.findall("testsuite")
|
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:
|
for suite in suites:
|
||||||
file_attr = suite.attrib.get("file", "")
|
file_attr = suite.attrib.get("file", "")
|
||||||
suite_name = suite.attrib.get("name", "")
|
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"))))
|
failures = int(float(suite.attrib.get("failures", root.attrib.get("failures", "0"))))
|
||||||
total_time = float(suite.attrib.get("time", root.attrib.get("time", "0")))
|
total_time = float(suite.attrib.get("time", root.attrib.get("time", "0")))
|
||||||
timestamp = parse_xml_timestamp(suite.attrib.get("timestamp"))
|
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:
|
if best is None:
|
||||||
best = candidate
|
best = candidate
|
||||||
continue
|
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):
|
if tests > best_tests or (tests == best_tests and total_time >= best_total_time):
|
||||||
best = candidate
|
best = candidate
|
||||||
if not best:
|
if not best:
|
||||||
return None
|
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"
|
detail = f"{tests} tests, {failures} failures"
|
||||||
|
if failures:
|
||||||
|
detail = append_failure_detail(detail, failure_detail)
|
||||||
status = "FAIL" if failures else "PASS"
|
status = "FAIL" if failures else "PASS"
|
||||||
return file_name, HostResult(
|
return file_name, HostResult(
|
||||||
host=file_name,
|
host=file_name,
|
||||||
@@ -423,6 +426,66 @@ def parse_duration_seconds(raw: str) -> Optional[float]:
|
|||||||
return hours * 3600 + minutes * 60 + seconds
|
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*<?xml|\n\s*Screenshots:|\n\s*Video output:|$)"
|
||||||
|
)
|
||||||
|
match = re.search(pattern, log_text, re.S)
|
||||||
|
if not match:
|
||||||
|
return None
|
||||||
|
testcase = match.group(1).strip()
|
||||||
|
body = compact_failure_detail(match.group(2))
|
||||||
|
if body:
|
||||||
|
return compact_failure_detail(f"{testcase} - {body}")
|
||||||
|
return compact_failure_detail(testcase)
|
||||||
|
|
||||||
|
|
||||||
def extract_host_results_from_run_finished_segment(segment_text: str, inventory: Dict[str, str]) -> Dict[str, HostResult]:
|
def extract_host_results_from_run_finished_segment(segment_text: str, inventory: Dict[str, str]) -> Dict[str, HostResult]:
|
||||||
host_results: Dict[str, HostResult] = {}
|
host_results: Dict[str, HostResult] = {}
|
||||||
normalized = re.sub(r"\n\s*│\s*s\s*│", "s", segment_text)
|
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:
|
for block in cloud_blocks:
|
||||||
block_text = block.group(1)
|
block_text = block.group(1)
|
||||||
currents_url = block.group(2)
|
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)
|
host_results = extract_host_results_from_run_finished_segment(block_text, inventory)
|
||||||
if not host_results:
|
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)
|
host_results = extract_host_results_from_run_finished_segment(prior_segment, inventory)
|
||||||
if not host_results:
|
if not host_results:
|
||||||
previous_block_end = block.end()
|
previous_block_end = block.end()
|
||||||
continue
|
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(
|
summaries.append(
|
||||||
{
|
{
|
||||||
"host_results": host_results,
|
"host_results": host_results,
|
||||||
@@ -631,6 +698,23 @@ def parse_host_reporter_json(artifact_path: Path, host: str, kernels: Dict[str,
|
|||||||
if pending:
|
if pending:
|
||||||
detail_parts.append(f"{pending} 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:
|
if failures:
|
||||||
status = "FAIL"
|
status = "FAIL"
|
||||||
elif pending:
|
elif pending:
|
||||||
@@ -638,11 +722,15 @@ def parse_host_reporter_json(artifact_path: Path, host: str, kernels: Dict[str,
|
|||||||
else:
|
else:
|
||||||
status = "PASS"
|
status = "PASS"
|
||||||
|
|
||||||
|
detail = ", ".join(detail_parts)
|
||||||
|
if failures:
|
||||||
|
detail = append_failure_detail(detail, failure_detail)
|
||||||
|
|
||||||
return HostResult(
|
return HostResult(
|
||||||
host=host,
|
host=host,
|
||||||
kernel=kernels.get(host, "unknown"),
|
kernel=kernels.get(host, "unknown"),
|
||||||
status=status,
|
status=status,
|
||||||
detail=", ".join(detail_parts),
|
detail=detail,
|
||||||
tests=tests,
|
tests=tests,
|
||||||
failures=failures,
|
failures=failures,
|
||||||
duration_seconds=float(duration_ms) / 1000 if duration_ms else None,
|
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
|
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"
|
status = "FAIL" if failures else "PASS"
|
||||||
detail = "1 failures" if failures else "completed"
|
detail = "1 failures" if failures else "completed"
|
||||||
|
if failures:
|
||||||
|
detail = append_failure_detail(detail, extract_failure_detail_from_text_blob(text))
|
||||||
return HostResult(
|
return HostResult(
|
||||||
host=host,
|
host=host,
|
||||||
kernel=kernels.get(host, "unknown"),
|
kernel=kernels.get(host, "unknown"),
|
||||||
|
|||||||
Reference in New Issue
Block a user