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')