scripts/metric_reporter/parser/junit_xml_parser.py (245 lines of code) (raw):
# This Source Code Form is subject to the terms of the Mozilla Public
# License, v. 2.0. If a copy of the MPL was not distributed with this
# file, You can obtain one at http://mozilla.org/MPL/2.0/.
"""Module for parsing test suite results from JUnit XML content."""
import logging
from typing import Any
import xmltodict
from pydantic import BaseModel, ValidationError, TypeAdapter
from scripts.metric_reporter.gcs_client import GCSClient
from scripts.metric_reporter.parser.base_parser import ArtifactFile, BaseParser, ParserError
class JestJUnitXmlTestCase(BaseModel):
"""Represents a test case in a test suite."""
name: str
classname: str
time: float
skipped: str | None = None
failure: str | None = None
class JestJUnitXmlTestSuite(BaseModel):
"""Represents a test suite containing multiple test cases."""
name: str
timestamp: str
tests: int
failures: int
skipped: int
time: float
errors: int
test_cases: list[JestJUnitXmlTestCase]
class JestJUnitXmlTestSuites(BaseModel):
"""Represents a collection of test suites."""
name: str
tests: int
failures: int
errors: int
time: float
test_suites: list[JestJUnitXmlTestSuite]
class MochaJUnitXmlFailure(BaseModel):
"""Represents a failure of a test case."""
message: str
type: str
text: str | None = None
class MochaJUnitXmlTestCase(BaseModel):
"""Represents a test case in a test suite."""
name: str
classname: str
time: float
skipped: str | None = None
failure: MochaJUnitXmlFailure | None = None
class MochaJUnitXmlTestSuite(BaseModel):
"""Represents a test suite containing multiple test cases."""
name: str
timestamp: str
tests: int
failures: int
file: str | None = None
time: float
test_cases: list[MochaJUnitXmlTestCase] | None = []
class MochaJUnitXmlTestSuites(BaseModel):
"""Represents a collection of test suites."""
name: str
tests: int
failures: int
skipped: int | None = None
time: float
test_suites: list[MochaJUnitXmlTestSuite] | None = []
class NextestJUnitXmlTestCase(BaseModel):
"""Represents a test case in a test suite."""
name: str
classname: str
timestamp: str
time: float | None = None
class NextestJUnitXmlTestSuite(BaseModel):
"""Represents a test suite containing multiple test cases."""
name: str
tests: int
failures: int
skipped: int
errors: int
test_cases: list[NextestJUnitXmlTestCase]
class NextestJUnitXmlTestSuites(BaseModel):
"""Represents a collection of test suites."""
name: str
tests: int
failures: int
errors: int
time: float
timestamp: str
uuid: str
test_suites: list[NextestJUnitXmlTestSuite]
class PlaywrightJUnitXmlProperty(BaseModel):
"""Represents a property of a test case."""
name: str
value: str
text: str | None = None
class PlaywrightJUnitXmlProperties(BaseModel):
"""Represents a property of a test case."""
property: list[PlaywrightJUnitXmlProperty]
class PlaywrightJUnitXmlFailure(BaseModel):
"""Represents a failure of a test case."""
message: str
type: str
text: str | None = None
class PlaywrightJUnitXmlTestCase(BaseModel):
"""Represents a test case in a test suite."""
name: str
classname: str | None = None
time: float | None = None
properties: PlaywrightJUnitXmlProperties | None = None
skipped: str | None = None
failure: PlaywrightJUnitXmlFailure | None = None
system_out: str | None = None
class PlaywrightJUnitXmlTestSuite(BaseModel):
"""Represents a test suite containing multiple test cases."""
name: str
timestamp: str
hostname: str
tests: int
failures: int
skipped: int
time: float
errors: int
test_cases: list[PlaywrightJUnitXmlTestCase]
class PlaywrightJUnitXmlTestSuites(BaseModel):
"""Represents a collection of test suites."""
id: str
name: str
tests: int
failures: int
skipped: int
errors: int
time: float
test_suites: list[PlaywrightJUnitXmlTestSuite]
class PytestJUnitXmlSkipped(BaseModel):
"""Represents a skipped test case."""
message: str
type: str
text: str | None = None
class PytestJUnitXmlFailure(BaseModel):
"""Represents a failure of a test case."""
message: str
text: str | None = None
class PytestJUnitXmlTestCase(BaseModel):
"""Represents a test case in a test suite."""
name: str
classname: str
time: float
skipped: PytestJUnitXmlSkipped | None = None
failure: PytestJUnitXmlFailure | None = None
class PytestJUnitXmlTestSuite(BaseModel):
"""Represents a test suite containing multiple test cases."""
name: str
timestamp: str
hostname: str
tests: int
failures: int
skipped: int
time: float
errors: int
test_cases: list[PytestJUnitXmlTestCase]
class PytestJUnitXmlTestSuites(BaseModel):
"""Represents a collection of test suites."""
test_suites: list[PytestJUnitXmlTestSuite]
class TapJUnitXmlTestCase(BaseModel):
"""Represents a test case in a test suite."""
name: str
class TapJUnitXmlTestSuite(BaseModel):
"""Represents a test suite containing multiple test cases."""
name: str
tests: int
failures: int
errors: int | None = None
test_cases: list[TapJUnitXmlTestCase]
class TapJUnitXmlTestSuites(BaseModel):
"""Represents a collection of test suites."""
test_suites: list[TapJUnitXmlTestSuite]
JUnitXmlTestSuites = (
JestJUnitXmlTestSuites
| MochaJUnitXmlTestSuites
| NextestJUnitXmlTestSuites
| PlaywrightJUnitXmlTestSuites
| PytestJUnitXmlTestSuites
| TapJUnitXmlTestSuites
)
class JUnitXmlJobTestSuites(BaseModel):
"""Represents test results from one or more JUnit XML files for a test run."""
job: int
job_timestamp: str
test_suites: list[JUnitXmlTestSuites] = []
class JUnitXmlGroup(BaseModel):
"""Represents test results for a repository/workflow/test_suite."""
repository: str
workflow: str
test_suite: str
junit_xmls: list[JUnitXmlJobTestSuites]
class JUnitXmlParser(BaseParser):
"""Parses JUnit XML files."""
logger = logging.getLogger(__name__)
def __init__(self, gcs_client: GCSClient) -> None:
"""Initialize the JUnitXmlParser.
Args:
gcs_client (GCSClient): GCS client.
"""
self._gcs_client = gcs_client
@staticmethod
def _get_junit_xml(
file: ArtifactFile, junit_xml_groups: list[JUnitXmlGroup]
) -> JUnitXmlJobTestSuites:
if junit_xml_group := next(
(
group
for group in junit_xml_groups
if group.repository == file.repository
and group.workflow == file.workflow
and group.test_suite == file.test_suite
),
None,
):
if not (
junit_xml := next(
(
junit_xmls
for junit_xmls in junit_xml_group.junit_xmls
if junit_xmls.job == file.job_number
and junit_xmls.job_timestamp == file.job_timestamp
),
None,
)
):
junit_xml = JUnitXmlJobTestSuites(
job=file.job_number, job_timestamp=file.job_timestamp
)
junit_xml_group.junit_xmls.append(junit_xml)
else:
junit_xml = JUnitXmlJobTestSuites(
job=file.job_number, job_timestamp=file.job_timestamp
)
junit_xml_group = JUnitXmlGroup(
repository=file.repository,
workflow=file.workflow,
test_suite=file.test_suite,
junit_xmls=[junit_xml],
)
junit_xml_groups.append(junit_xml_group)
return junit_xml
def _parse_test_suites(self, repository: str, artifact_file_name: str) -> JUnitXmlTestSuites:
def postprocessor(path, key, value):
key_mapping = {
"testsuite": "test_suites",
"testcase": "test_cases",
"system-out": "system_out", # Playwright
"disabled": "skipped", # Nextest skipped == disabled
"#text": "text",
}
key = key_mapping.get(key, key)
return key, value
content: str = self._gcs_client.get_junit_artifact_content(repository, artifact_file_name)
test_suites_dict: dict[str, Any] = xmltodict.parse(
content,
attr_prefix="",
postprocessor=postprocessor,
force_list=["test_suites", "test_cases", "property"],
)
adapter: TypeAdapter[JUnitXmlTestSuites] = TypeAdapter(JUnitXmlTestSuites)
test_suites: JUnitXmlTestSuites = adapter.validate_python(test_suites_dict["testsuites"])
return test_suites
def parse(self, artifact_file_names: list[str]) -> list[JUnitXmlGroup]:
"""Parse JUnit XML content from the specified directory.
Args:
artifact_file_names (str): Paths of the JUnit XML test files.
Returns:
list[JUnitXmlGroup]: A list of parsed JUnit XML files grouped by repository, workflow
and test suite.
Raises:
ParserError: If there is an error reading or parsing the XML files.
"""
junit_xml_groups: list[JUnitXmlGroup] = []
for artifact_file_name in artifact_file_names:
self.logger.info(f"Parsing {artifact_file_name}")
file: ArtifactFile = self._parse_artifact_file_name(artifact_file_name)
junit_xml: JUnitXmlJobTestSuites = self._get_junit_xml(file, junit_xml_groups)
try:
test_suites: JUnitXmlTestSuites = self._parse_test_suites(
file.repository, file.name
)
junit_xml.test_suites.append(test_suites)
except ValidationError as error:
error_msg: str = f"Unexpected value or schema in file {artifact_file_name}"
self.logger.error(error_msg, exc_info=error)
raise ParserError(error_msg) from error
return junit_xml_groups