diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 0e2fe7c5d0..3d37f4da07 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -44,7 +44,7 @@ repos:
- "urllib3>=1.26.5, <2.0" # 1.26.16 / 2.0.4
# report-junit
- - "junit_xml>=1.9"
+ - "lxml>=4.6.5"
- "typing-extensions>=4.9.0; python_version < '3.13'"
- "pytest"
@@ -62,6 +62,7 @@ repos:
- "types-jinja2"
- "types-babel"
- "types-docutils"
+ - "types-lxml"
pass_filenames: false
args: [--config-file=pyproject.toml]
@@ -90,7 +91,7 @@ repos:
- "urllib3>=1.26.5, <2.0" # 1.26.16 / 2.0.4
# report-junit
- - "junit_xml>=1.9"
+ - "lxml>=4.6.5"
- "typing-extensions>=4.9.0; python_version < '3.13'"
- "pytest"
@@ -108,6 +109,7 @@ repos:
- "types-jinja2"
- "types-babel"
- "types-docutils"
+ - "types-lxml"
- repo: https://github.com/python-jsonschema/check-jsonschema
rev: "0.29.2"
diff --git a/docs/releases.rst b/docs/releases.rst
index 29088f9b07..a7104bce60 100644
--- a/docs/releases.rst
+++ b/docs/releases.rst
@@ -4,6 +4,22 @@
Releases
======================
+tmt-1.37
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+The :ref:`/plugins/report/junit` report plugin now validates all the XML
+flavors against their respective XSD schemas and tries to prettify the final
+XML output. These functionalities are always disabled for ``custom`` flavors.
+The prettify functionality can be controlled for non-custom templates by
+``--prettify`` and ``--no-prettify`` arguments.
+
+The :ref:`/plugins/report/junit` report plugin now uses Jinja instead of
+``junit-xml`` library to generate the JUnit XMLs. It also adds support for a
+new ``--flavor`` argument. Using this argument the user can choose between a
+``default`` flavor, which keeps the current behavior untouched, and a
+``custom`` flavor where user must provide a custom template using a
+``--template-path`` argument.
+
tmt-1.37.0
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
diff --git a/pyproject.toml b/pyproject.toml
index 711bd25e27..30cc60acbb 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -61,7 +61,8 @@ provision-virtual = [
]
provision-container = []
report-junit = [
- "junit_xml>=1.9",
+ # Required to support XML parsing and checking the XSD schemas.
+ "lxml>=4.6.5",
]
report-polarion = [
"tmt[report-junit]",
@@ -139,6 +140,7 @@ dependencies = [
"types-jinja2",
"types-babel",
"types-docutils",
+ "types-lxml",
]
features = ["all"]
@@ -216,7 +218,7 @@ module = [
"guestfs.*",
"html2text.*",
"fmf.*",
- "junit_xml.*",
+ "lxml.*",
"libvirt.*",
"nitrate.*",
"pylero.*",
diff --git a/tests/report/junit/data/custom.xml.j2 b/tests/report/junit/data/custom.xml.j2
new file mode 100644
index 0000000000..faa5e55b4f
--- /dev/null
+++ b/tests/report/junit/data/custom.xml.j2
@@ -0,0 +1,6 @@
+
+
+ {% for result in RESULTS %}
+
+ {% endfor %}
+
diff --git a/tests/report/junit/data/main.fmf b/tests/report/junit/data/main.fmf
index 663550235c..86720109dd 100644
--- a/tests/report/junit/data/main.fmf
+++ b/tests/report/junit/data/main.fmf
@@ -27,3 +27,5 @@
/timeout:
test: sleep 10
duration: 2s
+ /escape"_chars:
+ test: "echo '\"chars'"
diff --git a/tests/report/junit/data/non-xml-custom.j2 b/tests/report/junit/data/non-xml-custom.j2
new file mode 100644
index 0000000000..523aae9b78
--- /dev/null
+++ b/tests/report/junit/data/non-xml-custom.j2
@@ -0,0 +1,3 @@
+{% for result in RESULTS %}
+name='{{ result.name | e }}',value='{{ result.result.value | e }}'
+{% endfor %}
diff --git a/tests/report/junit/test.sh b/tests/report/junit/test.sh
index 3cc3f175bc..8f41d396f7 100755
--- a/tests/report/junit/test.sh
+++ b/tests/report/junit/test.sh
@@ -8,16 +8,60 @@ rlJournalStart
rlPhaseEnd
for method in tmt; do
- rlPhaseStartTest "$method"
+ rlPhaseStartTest "[$method] Basic format checks"
rlRun "tmt run -avr execute -h $method report -h junit --file junit.xml 2>&1 >/dev/null | tee output" 2
- rlAssertGrep "2 tests passed, 2 tests failed and 2 errors" "output"
- rlAssertGrep '_chars (on default-0)' "output"
+ rlAssertGrep '' "junit.xml"
+
+ # Test the escape of special characters
+ rlAssertGrep '' "junit.xml"
+ rlAssertGrep '<speci&l>"chars' "junit.xml"
+
+ # Check there is no schema problem reported
+ rlAssertNotGrep 'The generated XML output is not a valid XML file or it is not valid against the XSD schema\.' "output"
+ rlPhaseEnd
+
+ rlPhaseStartTest "[$method] Check the flavor argument is working"
+ rlRun "tmt run -avr execute -h $method report -h junit --file junit.xml --flavor default 2>&1 >/dev/null | tee output" 2
+ rlAssertGrep "3 tests passed, 2 tests failed and 2 errors" "output"
+
+ # Check there is no schema problem reported
+ rlAssertNotGrep 'The generated XML output is not a valid XML file or it is not valid against the XSD schema\.' "output"
+ rlPhaseEnd
+
+ rlPhaseStartTest "[$method] Check the mutually exclusive arguments"
+ rlRun "tmt run -avr execute -h $method report -h junit --file junit.xml --flavor custom 2>&1 >/dev/null | tee output" 2
+ rlAssertGrep "The 'custom' flavor requires the '--template-path' argument." "output"
+
+ rlRun "tmt run -avr execute -h $method report -h junit --file junit.xml --template-path custom.xml.j2 2>&1 >/dev/null | tee output" 2
+ rlAssertGrep "The '--template-path' can be used only with '--flavor=custom'." "output"
+
+ rlPhaseEnd
+
+ rlPhaseStartTest "[$method] Check the 'custom' flavor with a custom XML template"
+ rlRun "tmt run -avr execute -h $method report -h junit --file custom-template-out.xml --template-path custom.xml.j2 --flavor custom 2>&1 >/dev/null | tee output" 2
+
+ # There must not be a schema check when using a custom flavor
+ rlAssertGrep "The 'custom' JUnit flavor is used, you are solely responsible for the validity of the XML schema\." "output"
+
+ rlAssertGrep '' "custom-template-out.xml"
+ rlAssertGrep '' "custom-template-out.xml"
+ rlAssertGrep '' "custom-template-out.xml"
+ rlAssertGrep '' "custom-template-out.xml"
+ rlAssertGrep '' "custom-template-out.xml"
+ rlPhaseEnd
+
+ rlPhaseStartTest "[$method] The 'custom' flavor with a custom **non-XML** template must not work"
+ rlRun "tmt run -avr execute -h $method report -h junit --file custom-template-out.xml --template-path non-xml-custom.j2 --flavor custom 2>&1 >/dev/null | tee output" 2
+
+ rlAssertGrep 'The generated XML output is not a valid XML file.' "output"
rlPhaseEnd
done
rlPhaseStartCleanup
- rlRun "rm output junit.xml"
+ rlRun "rm output junit.xml custom-template-out.xml"
rlRun "popd"
rlPhaseEnd
rlJournalEnd
diff --git a/tests/report/polarion/test.sh b/tests/report/polarion/test.sh
index 50c0a15a7e..d36eddc5a2 100755
--- a/tests/report/polarion/test.sh
+++ b/tests/report/polarion/test.sh
@@ -10,10 +10,11 @@ rlJournalStart
rlPhaseStartTest
rlRun "tmt run -avr execute report -h polarion --project-id RHELBASEOS --no-upload --planned-in RHEL-9.1.0 --file xunit.xml 2>&1 >/dev/null | tee output" 2
rlAssertGrep "1 test passed, 1 test failed and 1 error" "output"
- rlAssertGrep '' "xunit.xml"
rlAssertGrep '' "xunit.xml"
rlAssertGrep '' "xunit.xml"
+ rlAssertGrep "Maximum test time '2s' exceeded." "xunit.xml"
rlPhaseEnd
rlPhaseStartCleanup
diff --git a/tests/unit/test_report_junit.py b/tests/unit/test_report_junit.py
index aef8c46d74..fe9636694a 100644
--- a/tests/unit/test_report_junit.py
+++ b/tests/unit/test_report_junit.py
@@ -19,7 +19,7 @@ def report_fix(tmppath: Path, root_logger):
type(plan_mock).name = name_property
type(step_mock).plan = plan_mock
- out_file_path = str(tmppath / "out.xml")
+ out_file_path = Path(tmppath / "out.xml")
report = ReportJUnit(
logger=root_logger,
@@ -120,11 +120,14 @@ def test_pass(self, report_fix):
report.go()
- assert_xml(out_file_path, """
+ assert_xml(out_file_path, """
-
-
-
+
+
+
+
+
+
""")
@@ -133,13 +136,13 @@ def test_info(self, report_fix):
results.append(Result(result=ResultOutcome.INFO, name="/info", serial_number=1))
report.go()
- assert_xml(out_file_path, """
+ assert_xml(out_file_path, """
-
-
-
-
-
+
+
+
+
+
""")
@@ -148,13 +151,13 @@ def test_warn(self, report_fix):
results.append(Result(result=ResultOutcome.WARN, name="/warn", serial_number=1))
report.go()
- assert_xml(out_file_path, """
+ assert_xml(out_file_path, """
-
-
-
-
-
+
+
+
+
+
""")
@@ -163,13 +166,13 @@ def test_error(self, report_fix):
results.append(Result(result=ResultOutcome.ERROR, name="/error", serial_number=1))
report.go()
- assert_xml(out_file_path, """
+ assert_xml(out_file_path, """
-
-
-
-
-
+
+
+
+
+
""")
@@ -178,12 +181,12 @@ def test_fail(self, report_fix):
results.append(Result(result=ResultOutcome.FAIL, name="/fail", serial_number=1))
report.go()
- assert_xml(out_file_path, """
+ assert_xml(out_file_path, """
-
-
-
-
-
+
+
+
+
+
""")
diff --git a/tmt/steps/report/junit.py b/tmt/steps/report/junit.py
index 5093598c61..234f66f16a 100644
--- a/tmt/steps/report/junit.py
+++ b/tmt/steps/report/junit.py
@@ -1,5 +1,9 @@
import dataclasses
-from typing import TYPE_CHECKING, Optional, overload
+import functools
+from collections.abc import Iterator
+from typing import TYPE_CHECKING, Any, Optional, TypedDict, cast, overload
+
+from jinja2 import FileSystemLoader, select_autoescape
import tmt
import tmt.base
@@ -10,16 +14,24 @@
import tmt.steps.report
import tmt.utils
from tmt.plugins import ModuleImporter
+from tmt.result import ResultOutcome
from tmt.utils import Path, field
+from tmt.utils.templates import default_template_environment, render_template_file
if TYPE_CHECKING:
- import junit_xml
+ import lxml
+
+ from tmt._compat.typing import TypeAlias
+
+ XMLElement: TypeAlias = Any
- from tmt.steps.report import ReportPlugin
- from tmt.steps.report.polarion import ReportPolarionData
-DEFAULT_NAME = "junit.xml"
+DEFAULT_NAME = 'junit.xml'
+DEFAULT_FLAVOR_NAME = 'default'
+CUSTOM_FLAVOR_NAME = 'custom'
+# Relative path to tmt junit template directory.
+DEFAULT_TEMPLATE_DIR = Path('steps/report/junit/templates/')
# ignore[unused-ignore]: Pyright would report that "module cannot be
# used as a type", and it would be correct. On the other hand, it works,
@@ -31,10 +43,10 @@
# is enough to suppress it. And then mypy complains about an unused
# ignore, hence `unused-ignore` code, leading to apparently confusing
# directive.
-import_junit_xml: ModuleImporter['junit_xml'] = ModuleImporter( # type: ignore[unused-ignore]
- 'junit_xml',
+import_lxml: ModuleImporter['lxml'] = ModuleImporter( # type: ignore[valid-type]
+ 'lxml',
tmt.utils.ReportError,
- "Missing 'junit-xml', fixable by 'pip install tmt[report-junit]'.",
+ "Missing 'lxml', fixable by 'pip install tmt[report-junit]'.",
tmt.log.Logger.get_bootstrap_logger())
@@ -54,44 +66,232 @@ def duration_to_seconds(duration: Optional[str]) -> Optional[int]:
h, m, s = duration.split(':')
return int(h) * 3600 + int(m) * 60 + int(s)
except Exception as error:
- raise tmt.utils.ReportError(
- f"Malformed duration '{duration}' ({error}).")
+ raise tmt.utils.ReportError(f"Malformed duration '{duration}'.") from error
+
+
+class ResultWrapper:
+ """
+ The context wrapper for :py:class:`tmt.Result`.
+
+ Adds possibility to wrap the :py:class:`tmt.Result` and dynamically add more attributes which
+ get available inside the template context.
+ """
+
+ class PropertyDict(TypedDict):
+ """ Defines a property dict, which gets propagated into the final template properties. """
+
+ name: str
+ value: str
+
+ def __init__(self, wrapped: tmt.Result) -> None:
+ self._wrapped = wrapped
+ self._properties: dict[str, str] = {}
+
+ @property
+ def properties(self) -> list[PropertyDict]:
+ return [{'name': k, 'value': v} for k, v in self._properties.items()]
+
+ @properties.setter
+ def properties(self, keyval: dict[str, str]) -> None:
+ self._properties = keyval
+
+ def __getattr__(self, name: str) -> Any:
+ """ Returns an attribute of a wrapped ``tmt.Result`` instance """
+ return getattr(self._wrapped, name)
+
+
+class ResultsContext:
+ """
+ The results context for Jinja templates.
+
+ A class which keeps the results context (especially the result summary) for JUnit template. It
+ wraps all the :py:class:`tmt.Result` instances into the :py:class:`ResultWrapper`.
+ """
+
+ def __init__(self, results: list[tmt.Result]) -> None:
+ # Decorate all the tmt.Results with more attributes
+ self._results: list[ResultWrapper] = [ResultWrapper(r) for r in results]
+
+ def __iter__(self) -> Iterator[ResultWrapper]:
+ """ Possibility to iterate over results by iterating an instance """
+ return iter(self._results)
+
+ def __len__(self) -> int:
+ """ Returns the number of results """
+ return len(self._results)
+
+ @functools.cached_property
+ def passed(self) -> list[ResultWrapper]:
+ """ Returns results of passed tests """
+ return [r for r in self._results if r.result == ResultOutcome.PASS]
+
+ @functools.cached_property
+ def skipped(self) -> list[ResultWrapper]:
+ """ Returns results of skipped tests """
+ return [r for r in self._results if r.result == ResultOutcome.INFO]
+
+ @functools.cached_property
+ def failed(self) -> list[ResultWrapper]:
+ """ Returns results of failed tests """
+ return [r for r in self._results if r.result == ResultOutcome.FAIL]
+
+ @functools.cached_property
+ def errored(self) -> list[ResultWrapper]:
+ """ Returns results of tests with error/warn outcome """
+ return [r for r in self._results if r.result in [
+ ResultOutcome.ERROR,
+ ResultOutcome.WARN]]
+
+ @functools.cached_property
+ def duration(self) -> int:
+ """ Returns the total duration of all tests in seconds """
+ # cast: mypy does not understand the proxy-ness of `ResultWrapper`. `r.duration`
+ # will exists, therefore adding a `cast` to convince mypy the list is pretty much
+ # nothing but the list of results.
+ return sum(
+ duration_to_seconds(r.duration) or 0 for r in cast(list[tmt.Result], self._results))
def make_junit_xml(
- report: 'ReportPlugin[ReportJUnitData]|ReportPlugin[ReportPolarionData]'
- ) -> 'junit_xml.TestSuite':
- """ Create junit xml object """
- junit_xml = import_junit_xml()
+ phase: tmt.steps.report.ReportPlugin[Any],
+ flavor: str = DEFAULT_FLAVOR_NAME,
+ template_path: Optional[Path] = None,
+ include_output_log: bool = True,
+ prettify: bool = True,
+ results_context: Optional[ResultsContext] = None,
+ **extra_variables: Any
+ ) -> str:
+ """
+ Create JUnit XML file and return the data.
+
+ :param phase: instance of a :py:class:`tmt.steps.report.ReportPlugin`.
+ :param flavor: name of a JUnit flavor to generate.
+ :param template_path: if set, the provided template will be used instead of a pre-defined
+ flavor template. In this case, the ``flavor`` must be set to ``custom`` value.
+ :param include_output_log: if enabled, the ```` tags are included in the final
+ template output.
+ :param prettify: allows to control the XML pretty print.
+ :param results_context: if set, the provided :py:class:`ResultsContext` is used in a template.
+ :param extra_variables: if set, these variables get propagated into the Jinja template.
+ """
+
+ # Get the template context for tmt results
+ results_context = results_context or ResultsContext(phase.step.plan.execute.results())
+
+ # Prepare the template environment
+ environment = default_template_environment()
+
+ template_path = template_path or tmt.utils.resource_files(
+ DEFAULT_TEMPLATE_DIR / Path(f'{flavor}.xml.j2'))
+
+ # Use a FileSystemLoader for a non-custom flavor
+ if flavor != CUSTOM_FLAVOR_NAME:
+ environment.loader = FileSystemLoader(
+ searchpath=tmt.utils.resource_files(DEFAULT_TEMPLATE_DIR))
+
+ def _read_log(log: Path) -> str:
+ """ Read the contents of a given result log """
+ if not log:
+ return ''
- suite = junit_xml.TestSuite(report.step.plan.name)
+ try:
+ return str(phase.step.plan.execute.read(log))
+ except tmt.utils.FileError:
+ return ''
+
+ environment.filters.update({
+ 'read_log': _read_log,
+ 'duration_to_seconds': duration_to_seconds,
+ 'failures': tmt.result.Result.failures,
+ })
+
+ # Explicitly enable the autoescape because it's disabled by default by tmt.
+ # See /teemtee/tmt/issues/2873 for more info.
+ environment.autoescape = select_autoescape(enabled_extensions=('xml.j2'))
+
+ xml_data = render_template_file(
+ template_path,
+ environment,
+ RESULTS=results_context,
+ PLAN=phase.step.plan,
+ INCLUDE_OUTPUT_LOG=include_output_log,
+ **extra_variables)
+
+ # Try to use lxml to check the flavor XML schema and prettify the final XML
+ # output.
+ try:
+ from lxml import etree
+ except ImportError:
+ phase.warn(
+ "Install 'tmt[report-junit]' to support neater JUnit XML output and the XML schema "
+ "validation against the XSD.")
+ return xml_data
+
+ xml_parser_kwargs: dict[str, Any] = {
+ 'remove_blank_text': prettify,
+ 'schema': None,
+ }
+
+ # The schema check must be done only for a non-custom JUnit flavors
+ if flavor != CUSTOM_FLAVOR_NAME:
+ xsd_schema_path = Path(tmt.utils.resource_files(
+ Path(f'steps/report/junit/schemas/{flavor}.xsd')))
+
+ schema_root: XMLElement = etree.XML(xsd_schema_path.read_bytes())
+ xml_parser_kwargs['schema'] = etree.XMLSchema(schema_root)
+ else:
+ phase.warn(
+ f"The '{CUSTOM_FLAVOR_NAME}' JUnit flavor is used, you are solely responsible "
+ "for the validity of the XML schema.")
+
+ if prettify:
+ phase.warn(f"The pretty print is always disabled for '{CUSTOM_FLAVOR_NAME}' JUnit "
+ "flavor.")
+
+ xml_parser_kwargs['remove_blank_text'] = prettify = False
+
+ xml_parser = etree.XMLParser(**xml_parser_kwargs)
+ try:
+ # S320: Parsing of untrusted data is known to be vulnerable to XML
+ # attacks.
+ tree_root: XMLElement = etree.fromstring(xml_data, xml_parser) # noqa: S320
+
+ except etree.XMLSyntaxError as e:
+ phase.warn(
+ 'The generated XML output is not a valid XML file or it is not valid against the '
+ 'XSD schema.')
+
+ if flavor != CUSTOM_FLAVOR_NAME:
+ phase.warn('Please, report this problem to project maintainers.')
+
+ for err in e.error_log:
+ phase.warn(str(err))
+
+ # Return the prettified XML without checking the XSD
+ del xml_parser_kwargs['schema']
- for result in report.step.plan.execute.results():
try:
- main_log = report.step.plan.execute.read(result.log[0])
- except (IndexError, AttributeError):
- main_log = None
- case = junit_xml.TestCase(
- result.name,
- classname=None,
- elapsed_sec=duration_to_seconds(result.duration))
-
- if report.data.include_output_log:
- case.stdout = main_log
-
- # Map tmt OUTCOME to JUnit states
- if result.result == tmt.result.ResultOutcome.ERROR:
- case.add_error_info(result.result.value, output=result.failures(main_log))
- elif result.result == tmt.result.ResultOutcome.FAIL:
- case.add_failure_info(result.result.value, output=result.failures(main_log))
- elif result.result == tmt.result.ResultOutcome.INFO:
- case.add_skipped_info(result.result.value, output=result.failures(main_log))
- elif result.result == tmt.result.ResultOutcome.WARN:
- case.add_error_info(result.result.value, output=result.failures(main_log))
- # Passed state is the default
- suite.test_cases.append(case)
-
- return suite
+ tree_root = etree.fromstring( # noqa: S320
+ xml_data, etree.XMLParser(**xml_parser_kwargs))
+ except etree.XMLSyntaxError as error:
+ phase.verbose('rendered XML', xml_data, 'red')
+ raise tmt.utils.ReportError(
+ 'The generated XML output is not a valid XML file. Use `--verbose` argument '
+ 'to show the output.') from error
+
+ # Do not be fooled by the `encoding` parameter: even with `utf-8`,
+ # `tostring()` will still return bytes. `unicode`, on the other
+ # hand, would give us a string, but then, no XML declaration.
+ # So, we get bytes, and we need to apply `decode()` on our own.
+ xml_output: bytes = etree.tostring(
+ tree_root,
+ xml_declaration=True,
+ pretty_print=prettify,
+ # The 'utf-8' encoding must be used instead of 'unicode', otherwise
+ # the XML declaration is not included in the output.
+ encoding='utf-8')
+
+ return str(xml_output.decode('utf-8'))
@dataclasses.dataclass
@@ -101,7 +301,31 @@ class ReportJUnitData(tmt.steps.report.ReportStepData):
option='--file',
metavar='PATH',
help='Path to the file to store JUnit to.',
- normalize=lambda key_address, raw_value, logger: Path(raw_value) if raw_value else None)
+ normalize=tmt.utils.normalize_path)
+
+ flavor: str = field(
+ default=DEFAULT_FLAVOR_NAME,
+ option='--flavor',
+ choices=[DEFAULT_FLAVOR_NAME, CUSTOM_FLAVOR_NAME],
+ help='Name of a JUnit flavor to generate.')
+
+ template_path: Optional[Path] = field(
+ default=None,
+ option='--template-path',
+ metavar='TEMPLATE_PATH',
+ help='Path to a custom template file to use for JUnit creation.',
+ normalize=tmt.utils.normalize_path)
+
+ prettify: bool = field(
+ default=True,
+ option=('--prettify / --no-prettify'),
+ is_flag=True,
+ show_default=True,
+ help=f"""
+ Enable the XML pretty print for generated JUnit file. This option is always disabled
+ for '{CUSTOM_FLAVOR_NAME}' template flavor.
+ """
+ )
include_output_log: bool = field(
default=True,
@@ -114,14 +338,28 @@ class ReportJUnitData(tmt.steps.report.ReportStepData):
@tmt.steps.provides_method('junit')
class ReportJUnit(tmt.steps.report.ReportPlugin[ReportJUnitData]):
"""
- Save test results in JUnit format.
+ Save test results in chosen JUnit flavor format.
- When ``file`` is not specified, output is written into a file
- named ``junit.xml`` located in the current workdir.
+ When flavor is set to custom, the ``template-path`` with a path to a custom template must be
+ provided.
+
+ When ``file`` is not specified, output is written into a file named ``junit.xml`` located in
+ the current workdir.
"""
_data_class = ReportJUnitData
+ def check_options(self) -> None:
+ """ Check the module options """
+
+ if self.data.flavor == 'custom' and not self.data.template_path:
+ raise tmt.utils.ReportError(
+ "The 'custom' flavor requires the '--template-path' argument.")
+
+ if self.data.flavor != 'custom' and self.data.template_path:
+ raise tmt.utils.ReportError(
+ "The '--template-path' can be used only with '--flavor=custom'.")
+
def prune(self, logger: tmt.log.Logger) -> None:
""" Do not prune generated junit report """
@@ -129,19 +367,19 @@ def go(self, *, logger: Optional[tmt.log.Logger] = None) -> None:
""" Read executed tests and write junit """
super().go(logger=logger)
- junit_xml = import_junit_xml()
- suite = make_junit_xml(self)
+ self.check_options()
assert self.workdir is not None
f_path = self.data.file or self.workdir / DEFAULT_NAME
+
+ xml_data = make_junit_xml(
+ phase=self,
+ flavor=self.data.flavor,
+ template_path=self.data.template_path,
+ include_output_log=self.data.include_output_log,
+ prettify=self.data.prettify)
try:
- with open(f_path, 'w') as fw:
- if hasattr(junit_xml, 'to_xml_report_file'):
- junit_xml.to_xml_report_file(fw, [suite])
- else:
- # For older junit-xml
- junit_xml.TestSuite.to_file(fw, [suite])
- self.info("output", f_path, 'yellow')
+ f_path.write_text(xml_data)
+ self.info('output', f_path, 'yellow')
except Exception as error:
- raise tmt.utils.ReportError(
- f"Failed to write the output '{f_path}' ({error}).")
+ raise tmt.utils.ReportError(f"Failed to write the output '{f_path}'.") from error
diff --git a/tmt/steps/report/junit/schemas/default.xsd b/tmt/steps/report/junit/schemas/default.xsd
new file mode 100644
index 0000000000..dd7632ada2
--- /dev/null
+++ b/tmt/steps/report/junit/schemas/default.xsd
@@ -0,0 +1,80 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tmt/steps/report/junit/templates/_base.xml.j2 b/tmt/steps/report/junit/templates/_base.xml.j2
new file mode 100644
index 0000000000..50edfab93b
--- /dev/null
+++ b/tmt/steps/report/junit/templates/_base.xml.j2
@@ -0,0 +1,30 @@
+
+{% block content %}
+
+ {% block testsuites %}
+
+ {% block testcases %}
+ {% for result in RESULTS %}
+ {% set main_log = result.log | first | read_log %}
+ {% set log_failures = main_log | failures | e %}
+ {% set test_duration = result.duration | duration_to_seconds | float %}
+
+
+ {% if result.result.value == 'error' or result.result.value == 'warn' %}
+ {{ log_failures }}
+ {% elif result.result.value == 'fail' %}
+ {{ log_failures }}
+ {% elif result.result.value == 'info' %}
+ {{ log_failures }}
+ {% endif %}
+
+ {% if INCLUDE_OUTPUT_LOG and main_log %}
+ {{ main_log | e }}
+ {% endif %}
+
+ {% endfor %}
+ {% endblock %}
+
+ {% endblock %}
+
+{% endblock %}
diff --git a/tmt/steps/report/junit/templates/default.xml.j2 b/tmt/steps/report/junit/templates/default.xml.j2
new file mode 100644
index 0000000000..207af2f89d
--- /dev/null
+++ b/tmt/steps/report/junit/templates/default.xml.j2
@@ -0,0 +1 @@
+{% extends "_base.xml.j2" %}
diff --git a/tmt/steps/report/polarion.py b/tmt/steps/report/polarion.py
index abf81ef707..7478b96540 100644
--- a/tmt/steps/report/polarion.py
+++ b/tmt/steps/report/polarion.py
@@ -224,9 +224,18 @@ def go(self, *, logger: Optional[tmt.log.Logger] = None) -> None:
'description', 'planned_in', 'assignee', 'pool_team', 'arch', 'platform', 'build',
'sample_image', 'logs', 'compose_id', 'fips']
- junit_suite = make_junit_xml(self)
+ xml_data = make_junit_xml(
+ phase=self,
+
+ # TODO: Explicitly use 'default' flavor until the 'polarion' flavor
+ # gets implemented in junit report plugin.
+ # flavor='polarion',
+ flavor='default',
+
+ include_output_log=self.data.include_output_log)
+
# S314: Any potential xml parser vulnerability mitigation would require defusedxml package
- xml_tree = ET.fromstring(junit_suite.to_xml_string([junit_suite])) # noqa: S314
+ xml_tree = ET.fromstring(xml_data) # noqa: S314
properties = {
'polarion-project-id': project_id,
'polarion-user-id': PolarionWorkItem._session.user_id,
@@ -290,7 +299,7 @@ def go(self, *, logger: Optional[tmt.log.Logger] = None) -> None:
f_path = self.data.file or self.workdir / DEFAULT_NAME
with open(f_path, 'wb') as fw:
- ET.ElementTree(xml_tree).write(fw)
+ ET.ElementTree(xml_tree).write(fw, xml_declaration=True, encoding='utf-8')
if upload:
server_url = str(PolarionWorkItem._session._server.url)
@@ -312,4 +321,4 @@ def go(self, *, logger: Optional[tmt.log.Logger] = None) -> None:
self.info(
"curl -k -u : -X POST -F file=@ "
"/polarion/import/xunit")
- self.info(f"xUnit file saved at: {f_path}")
+ self.info('xUnit file saved at', f_path, 'yellow')