blob: 1827597307bd19e662b8139a4055e3e9b4ed2378 [file]
# Copyright 2026 The Fuchsia Authors. All rights reserved.
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
"""Structured summary reporting for `fx test`.
Provides models and utilities for generating a unified machine-readable
summary JSON of test execution (including both host and device tests),
as well as tracking per-test and build log artifact paths.
"""
from __future__ import annotations
from dataclasses import dataclass
from dataclasses import field
import json
import os
import re
import typing
@dataclass
class Suggestion:
"""Represents a structured suggestion when a test target was not found."""
name: str
similarity: float | None = None
build_includes: str | None = None
add_test_command: str | None = None
def to_dict(self) -> dict[str, typing.Any]:
d: dict[str, typing.Any] = {
"name": str(self.name),
}
if self.similarity is not None:
d["similarity"] = float(self.similarity)
if self.build_includes is not None:
d["build_includes"] = str(self.build_includes)
if self.add_test_command is not None:
d["add_test_command"] = str(self.add_test_command)
return d
def parse_suggestions_from_output(raw_output: str) -> list[Suggestion]:
"""Parse raw output lines from search-tests into structured Suggestion objects."""
suggestions: list[Suggestion] = []
current_suggestion: Suggestion | None = None
for line in raw_output.splitlines():
line_str = line.strip()
if (
not line_str
or line_str.startswith("(")
and line_str.endswith("matches not shown)")
):
continue
similarity_match = re.match(
r"^(.+?)\s*\(([0-9.]+)%\s*similar\)", line_str
)
if similarity_match:
if current_suggestion is not None:
suggestions.append(current_suggestion)
name = similarity_match.group(1).strip()
similarity = float(similarity_match.group(2)) / 100.0
current_suggestion = Suggestion(name=name, similarity=similarity)
elif current_suggestion is not None:
if line_str.startswith("Build includes:"):
current_suggestion.build_includes = line_str.removeprefix(
"Build includes:"
).strip()
elif line_str.startswith("fx add-test"):
current_suggestion.add_test_command = line_str
else:
suggestions.append(current_suggestion)
current_suggestion = Suggestion(name=line_str)
else:
if line_str.startswith("Build includes:") or line_str.startswith(
"fx add-test"
):
continue
current_suggestion = Suggestion(name=line_str)
if current_suggestion is not None:
suggestions.append(current_suggestion)
return suggestions
@dataclass
class BuildResult:
"""Represents the outcome of the build phase before test execution."""
status: str
exit_code: int | None = None
log_path: str | None = None
error: str | None = None
def to_dict(self) -> dict[str, typing.Any]:
d: dict[str, typing.Any] = {
"status": str(self.status),
}
if self.exit_code is not None:
d["exit_code"] = int(self.exit_code)
if self.log_path is not None and isinstance(self.log_path, str):
d["log_path"] = self.log_path
if self.error is not None and isinstance(self.error, str):
d["error"] = self.error
return d
@dataclass
class TestResult:
"""Represents the execution outcome of an individual test suite."""
name: str
type: str # "host" or "device"
outcome: str # "PASSED", "FAILED", "SKIPPED", "TIMEOUT", "ABORTED", "FAILED_TO_START"
exit_code: int | None = None
log_path: str | None = None
stdout_log_path: str | None = None
stderr_log_path: str | None = None
duration_seconds: float | None = None
message: str | None = None
def to_dict(self) -> dict[str, typing.Any]:
d: dict[str, typing.Any] = {
"name": str(self.name),
"type": str(self.type),
"outcome": str(self.outcome),
}
if self.exit_code is not None:
d["exit_code"] = int(self.exit_code)
if self.log_path is not None and isinstance(self.log_path, str):
d["log_path"] = self.log_path
if self.stdout_log_path is not None and isinstance(
self.stdout_log_path, str
):
d["stdout_log_path"] = self.stdout_log_path
if self.stderr_log_path is not None and isinstance(
self.stderr_log_path, str
):
d["stderr_log_path"] = self.stderr_log_path
if self.duration_seconds is not None:
if isinstance(self.duration_seconds, (int, float)):
d["duration_seconds"] = float(self.duration_seconds)
else:
try:
d["duration_seconds"] = float(str(self.duration_seconds))
except (ValueError, TypeError):
pass
if self.message is not None and isinstance(self.message, str):
d["message"] = self.message
return d
@dataclass
class RunSummary:
"""Unified summary of an fx test run."""
outcome: str = "PASSED" # "PASSED", "FAILED", "BUILD_FAILED", "ERROR"
summary_path: str | None = None
error: str | None = None
total_passed: int = 0
total_failed: int = 0
total_skipped: int = 0
build: BuildResult | None = None
tests: list[TestResult] = field(default_factory=list)
suggestions: list[Suggestion] = field(default_factory=list)
hints: list[str] = field(default_factory=list)
def add_test(self, test: TestResult) -> None:
"""Add a test result and update counters."""
self.tests.append(test)
if test.outcome == "PASSED":
self.total_passed += 1
elif test.outcome == "SKIPPED":
self.total_skipped += 1
else:
self.total_failed += 1
def to_markdown(self) -> str:
"""Returns a Markdown string representation of the test suite."""
# Determine overall outcome, factoring in build failures
if self.build and getattr(self.build, "status", "PASSED") != "PASSED":
self.outcome = "BUILD_FAILED"
lines = [f"# Test Run: {self.outcome}"]
# Build Status
if self.build:
lines.append(f"- **Build**: {self.build.status}")
if getattr(self.build, "exit_code", None) is not None:
lines.append(f" - exit code: {self.build.exit_code}")
if getattr(self.build, "log_path", None) is not None:
lines.append(f" - logs: `{self.build.log_path}`")
if getattr(self.build, "error", None) is not None:
lines.append(f" - error: {self.build.error}")
# Add basic stats
lines.append(
f"- **Results**: {self.total_passed} Passed | {self.total_failed} Failed | {self.total_skipped} Skipped"
)
if self.summary_path:
lines.append(f"- **Summary JSON**: `{self.summary_path}`")
lines.append("")
# Test case details
if self.tests:
lines.append("## Tests")
for t in self.tests:
duration_str = (
f"{t.duration_seconds:.2f}s"
if t.duration_seconds is not None
else "0.00s"
)
lines.append(f"- [{t.outcome}] `{t.name}` ({duration_str})")
if t.stdout_log_path:
lines.append(f" - stdout: `{t.stdout_log_path}`")
elif t.log_path:
# Fallback to general log_path if stdout is not set
lines.append(f" - stdout: `{t.log_path}`")
if t.stderr_log_path:
lines.append(f" - stderr: `{t.stderr_log_path}`")
if t.message:
lines.append(f" - Message: {t.message}")
if self.suggestions:
lines.append("")
lines.append("## Suggestions")
for s in self.suggestions:
if s.similarity is not None:
sim_pct = int(s.similarity * 100)
lines.append(f"- **{s.name}** ({sim_pct}% match)")
else:
lines.append(f"- **{s.name}**")
if s.add_test_command:
lines.append(f" - Run: `{s.add_test_command}`")
if self.hints:
lines.append("")
lines.append("## Hints")
for h in self.hints:
lines.append(f"- {h}")
return "\n".join(lines)
def finalize(self, error: str | None = None) -> None:
"""Compute the final overall outcome if not already explicitly set."""
if error is not None:
self.error = str(error)
if self.build is not None and self.build.status == "FAILED":
self.outcome = "BUILD_FAILED"
elif (
self.error is not None
and not self.tests
and (self.build is None or self.build.status != "FAILED")
):
self.outcome = "ERROR"
elif self.total_failed > 0:
self.outcome = "FAILED"
else:
self.outcome = "PASSED"
def to_dict(self) -> dict[str, typing.Any]:
"""Convert the summary to a JSON-serializable dictionary."""
data: dict[str, typing.Any] = {
"outcome": str(self.outcome),
"total_passed": self.total_passed,
"total_failed": self.total_failed,
"total_skipped": self.total_skipped,
}
if self.summary_path is not None and isinstance(self.summary_path, str):
data["summary_path"] = self.summary_path
if self.error is not None and isinstance(self.error, str):
data["error"] = self.error
if self.build is not None:
data["build"] = self.build.to_dict()
if self.suggestions:
data["suggestions"] = [s.to_dict() for s in self.suggestions]
if self.hints:
data["hints"] = [str(h) for h in self.hints]
data["tests"] = [t.to_dict() for t in self.tests]
return data
def to_json(self, indent: int = 2) -> str:
"""Serialize the summary to a formatted JSON string."""
return json.dumps(self.to_dict(), indent=indent)
def write_to_file(self, target_path: str) -> None:
"""Write the summary JSON to disk, ensuring directory existence."""
abs_path = os.path.abspath(target_path)
self.summary_path = abs_path
os.makedirs(os.path.dirname(abs_path), exist_ok=True)
with open(abs_path, "w", encoding="utf-8") as f:
json.dump(self.to_dict(), f, indent=2)
f.write("\n")
def get_logs_dir(
summary_json: str | None = None,
artifact_output_directory: str | None = None,
) -> str | None:
"""Determine the base directory for isolated log files."""
if summary_json:
return os.path.join(
os.path.dirname(os.path.abspath(summary_json)),
"logs",
)
elif artifact_output_directory:
return os.path.join(
os.path.abspath(artifact_output_directory),
"logs",
)
return None