diff --git a/docs/releases.rst b/docs/releases.rst index b051a1f667..16937b712c 100644 --- a/docs/releases.rst +++ b/docs/releases.rst @@ -22,6 +22,9 @@ support for submitting jobs on behalf of a group through the ``beaker-job-group`` key. The submitting user must be a member of the given job group. +The ``note`` field of tmt :ref:`/spec/plans/results` changes from +a string to a list of strings, to better accommodate multiple notes. + tmt-1.40.0 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/spec/plans/results.fmf b/spec/plans/results.fmf index 732ae155c6..c1a4b044eb 100644 --- a/spec/plans/results.fmf +++ b/spec/plans/results.fmf @@ -33,8 +33,9 @@ description: | # String, the original outcome of the test execution. original-result: "pass"|"fail"|"info"|"warn"|"error"|"skip" - # String, optional comment to report with the result. - note: "Things were great." + # List of strings, optional comments to report with the result. + note: + - Things were great. # List of strings, paths to log files. log: @@ -88,8 +89,9 @@ description: | # String, the original outcome of the check execution. original-result: "pass"|"fail"|"info"|"warn"|"error"|"skip" - # String, optional comment to report with the result. - note: "Things were great." + # List of strings, optional comments to report with the result. + note: + - Things were great. # List of strings, paths to logs files. log: @@ -126,8 +128,9 @@ description: | # String, the original outcome of the phase execution. original-result: "pass"|"fail"|"info"|"warn"|"error"|"skip" - # String, optional comment to report with the result. - note: "Things were great." + # List of strings, optional comments to report with the result. + note: + - Things were great. # List of strings, paths to log files. log: @@ -256,6 +259,9 @@ description: | .. versionchanged:: 1.37 original result of test, subtest and check is stored in ``original-result`` key. + .. versionchanged:: 1.41 + ``note`` field changed from a string to a list of strings. + __ https://github.com/teemtee/tmt/blob/main/tmt/schemas/results.yaml example: @@ -269,7 +275,8 @@ example: start-time: "2023-03-10T09:44:14.439120+00:00" end-time: "2023-03-10T09:44:24.242227+00:00" duration: 00:00:09 - note: good result + note: + - good result ids: extra-nitrate: some-nitrate-id guest: @@ -284,7 +291,8 @@ example: start-time: "2023-03-10T09:44:14.439120+00:00" end-time: "2023-03-10T09:44:24.242227+00:00" duration: 00:00:09 - note: fail result + note: + - fail result guest: name: default-0 @@ -295,7 +303,8 @@ example: log: - pass_log duration: 00:11:22 - note: good result + note: + - good result ids: extra-nitrate: some-nitrate-id @@ -305,7 +314,8 @@ example: - fail_log - another_log duration: 00:22:33 - note: fail result + note: + - fail result - | # Example of a perfectly valid, yet stingy custom results file @@ -325,7 +335,8 @@ example: start-time: "2023-03-10T09:44:14.439120+00:00" end-time: "2023-03-10T09:44:24.242227+00:00" duration: 00:00:09 - note: good result + note: + - good result ids: extra-nitrate: some-nitrate-id guest: @@ -335,12 +346,12 @@ example: event: after-test result: pass log: [] - note: + note: [] - name: kernel-panic event: after-test result: pass log: [] - note: + note: [] - | # syntax: json @@ -351,7 +362,7 @@ example: "result": "pass", "log": ["pass_log"], "duration": "00:11:22", - "note": "good result" + "note": ["good result"] } ] diff --git a/tests/execute/restraint/tmt-abort/test.sh b/tests/execute/restraint/tmt-abort/test.sh index 3080efdb9c..a4ca7cff0b 100755 --- a/tests/execute/restraint/tmt-abort/test.sh +++ b/tests/execute/restraint/tmt-abort/test.sh @@ -14,8 +14,11 @@ rlJournalStart rlAssertNotGrep "This test should not be executed." $rlRun_LOG rlAssertNotGrep "This should not be executed either." $rlRun_LOG + rlAssertGrep "result: error" "${run}/plan/execute/results.yaml" - rlAssertGrep "note: aborted" "${run}/plan/execute/results.yaml" + rlAssertEquals "results should record the test aborted" \ + "$(yq -r '.[] | .note | join(", ")' ${run}/plan/execute/results.yaml)" \ + "beakerlib: State 'started', aborted" rlPhaseEnd rlPhaseStartCleanup diff --git a/tests/execute/result/custom/results.json b/tests/execute/result/custom/results.json index c13bd60af0..dcb344f06c 100644 --- a/tests/execute/result/custom/results.json +++ b/tests/execute/result/custom/results.json @@ -2,7 +2,9 @@ { "name": "/test/passing", "result": "pass", - "note": "good result", + "note": [ + "good result" + ], "log": [ "pass_log" ], @@ -28,7 +30,9 @@ "another-id": "bar2" }, "duration": "00:23:34", - "note": "fail result", + "note": [ + "fail result" + ], "serial-number": 2, "guest": { "name": "client-1", diff --git a/tests/execute/result/custom/results.yaml b/tests/execute/result/custom/results.yaml index eb21489ad8..3857338081 100644 --- a/tests/execute/result/custom/results.yaml +++ b/tests/execute/result/custom/results.yaml @@ -1,6 +1,7 @@ - name: /test/passing result: pass - note: good result + note: + - good result log: - pass_log ids: @@ -20,7 +21,8 @@ some-id: foo2 another-id: bar2 duration: 00:22:33 - note: fail result + note: + - fail result serial-number: 2 guest: name: client-1 diff --git a/tests/report/html/test.sh b/tests/report/html/test.sh index 6600d7fad8..e0bcb1a4cc 100755 --- a/tests/report/html/test.sh +++ b/tests/report/html/test.sh @@ -37,13 +37,14 @@ rlJournalStart grep -B 1 "/test/$test_name_suffix" $HTML | tee $tmp/$test_name_suffix rlAssertGrep 'class="result error">error' $tmp/$test_name_suffix -F sed -e "/name\">\/test\/$test_name_suffix/,/\/tr/!d" $HTML | tee $tmp/$test_name_suffix-note - rlAssertGrep 'timeout' $tmp/$test_name_suffix-note -F + rlAssertGrep '
  • timeout
  • ' $tmp/$test_name_suffix-note -F test_name_suffix=xfail grep -B 1 "/test/$test_name_suffix" $HTML | tee $tmp/$test_name_suffix rlAssertGrep 'class="result pass">pass' $tmp/$test_name_suffix -F sed -e "/name\">\/test\/$test_name_suffix/,/\/tr/!d" $HTML | tee $tmp/$test_name_suffix-note - rlAssertGrep 'test failed as expected, original test result: fail' $tmp/$test_name_suffix-note -F + rlAssertGrep '
  • test failed as expected
  • ' $tmp/$test_name_suffix-note -F + rlAssertGrep '
  • original test result: fail
  • ' $tmp/$test_name_suffix-note -F rlPhaseEnd if [ "$option" = "" ]; then diff --git a/tests/unit/test_results.py b/tests/unit/test_results.py index 53540a8595..a0ddeb69aa 100644 --- a/tests/unit/test_results.py +++ b/tests/unit/test_results.py @@ -1,4 +1,3 @@ -from typing import Optional from unittest.mock import MagicMock import pytest @@ -100,14 +99,14 @@ def test_result_to_exit_code(outcomes: list[ResultOutcome], expected_exit_code: ResultInterpret.RESPECT, {"check1": CheckResultInterpret.RESPECT}, ResultOutcome.PASS, - None + [] ), ( ResultOutcome.FAIL, ResultInterpret.RESPECT, {"check1": CheckResultInterpret.RESPECT}, ResultOutcome.FAIL, - "check 'check1' failed" # Note is set when check fails + ["check 'check1' failed"] # Note is set when check fails ), # Test XFAIL interpretation @@ -116,14 +115,14 @@ def test_result_to_exit_code(outcomes: list[ResultOutcome], expected_exit_code: ResultInterpret.XFAIL, {"check1": CheckResultInterpret.RESPECT}, ResultOutcome.PASS, - "check 'check1' failed, test failed as expected, original test result: fail" + ["check 'check1' failed", "test failed as expected", "original test result: fail"] ), ( ResultOutcome.PASS, ResultInterpret.XFAIL, {"check1": CheckResultInterpret.RESPECT}, ResultOutcome.FAIL, - "test was expected to fail, original test result: pass" + ["test was expected to fail", "original test result: pass"] ), # Test INFO interpretation @@ -132,14 +131,14 @@ def test_result_to_exit_code(outcomes: list[ResultOutcome], expected_exit_code: ResultInterpret.INFO, {"check1": CheckResultInterpret.RESPECT}, ResultOutcome.INFO, - "check 'check1' failed, test result overridden: info, original test result: fail" + ["check 'check1' failed", "test result overridden: info", "original test result: fail"] ), ( ResultOutcome.PASS, ResultInterpret.INFO, {"check1": CheckResultInterpret.RESPECT}, ResultOutcome.INFO, - "test result overridden: info, original test result: pass" + ["test result overridden: info", "original test result: pass"] ), # Test WARN interpretation @@ -148,7 +147,7 @@ def test_result_to_exit_code(outcomes: list[ResultOutcome], expected_exit_code: ResultInterpret.WARN, {"check1": CheckResultInterpret.RESPECT}, ResultOutcome.WARN, - "test result overridden: warn, original test result: pass" + ["test result overridden: warn", "original test result: pass"] ), # Test ERROR interpretation @@ -157,7 +156,7 @@ def test_result_to_exit_code(outcomes: list[ResultOutcome], expected_exit_code: ResultInterpret.ERROR, {"check1": CheckResultInterpret.RESPECT}, ResultOutcome.ERROR, - "test result overridden: error, original test result: pass" + ["test result overridden: error", "original test result: pass"] ), # Test CUSTOM interpretation (should not modify result) @@ -166,7 +165,7 @@ def test_result_to_exit_code(outcomes: list[ResultOutcome], expected_exit_code: ResultInterpret.CUSTOM, {"check1": CheckResultInterpret.RESPECT}, ResultOutcome.FAIL, - None + [] ), ], ids=[ @@ -183,7 +182,7 @@ def test_result_interpret_all_cases( interpret: ResultInterpret, interpret_checks: dict[str, CheckResultInterpret], expected_outcome: ResultOutcome, - expected_note_contains: Optional[str] + expected_note_contains: list[str] ) -> None: """Test all possible combinations of result interpretations""" result = Result( @@ -198,10 +197,11 @@ def test_result_interpret_all_cases( assert interpreted.result == expected_outcome if expected_note_contains: - assert interpreted.note is not None - assert expected_note_contains in interpreted.note + assert interpreted.note + for expected_note in expected_note_contains: + assert expected_note in interpreted.note else: - assert interpreted.note is None + assert not interpreted.note def test_result_interpret_check_phases() -> None: @@ -238,10 +238,10 @@ def test_result_interpret_edge_cases() -> None: result = Result(name="test-case", result=ResultOutcome.FAIL) interpreted = result.interpret_result(ResultInterpret.RESPECT, {}) assert interpreted.result == ResultOutcome.FAIL - assert interpreted.note is None + assert not interpreted.note # Test with empty check list result = Result(name="test-case", result=ResultOutcome.FAIL, check=[]) interpreted = result.interpret_result(ResultInterpret.RESPECT, {}) assert interpreted.result == ResultOutcome.FAIL - assert interpreted.note is None + assert not interpreted.note diff --git a/tmt/frameworks/beakerlib.py b/tmt/frameworks/beakerlib.py index 62ce52f6c8..2b627461e3 100644 --- a/tmt/frameworks/beakerlib.py +++ b/tmt/frameworks/beakerlib.py @@ -1,5 +1,5 @@ import re -from typing import TYPE_CHECKING, Optional +from typing import TYPE_CHECKING import tmt.base import tmt.log @@ -95,7 +95,7 @@ def extract_results( subresults = [result.to_subresult() for result in results] # Initialize data, prepare log paths - note: Optional[str] = None + note: list[str] = [] log: list[Path] = [] for filename in [tmt.steps.execute.TEST_OUTPUT_FILENAME, 'journal.txt']: if (invocation.path / filename).is_file(): @@ -108,7 +108,7 @@ def extract_results( beakerlib_results = invocation.phase.read(beakerlib_results_filepath, level=3) except tmt.utils.FileError: logger.debug(f"Unable to read '{beakerlib_results_filepath}'.", level=3) - note = 'beakerlib: TestResults FileError' + note.append('beakerlib: TestResults FileError') return [tmt.Result.from_test_invocation( invocation=invocation, @@ -133,7 +133,7 @@ def extract_results( logger.debug( f"No '{missing_piece}' found in '{beakerlib_results_filepath}'{hint}.", level=3) - note = 'beakerlib: Result/State missing' + note.append('beakerlib: Result/State missing') return [tmt.Result.from_test_invocation( invocation=invocation, result=ResultOutcome.ERROR, @@ -147,15 +147,15 @@ def extract_results( # Check if it was killed by timeout (set by tmt executor) actual_result = ResultOutcome.ERROR if invocation.return_code == tmt.utils.ProcessExitCodes.TIMEOUT: - note = 'timeout' + note.append('timeout') invocation.phase.timeout_hint(invocation) elif tmt.utils.ProcessExitCodes.is_pidfile(invocation.return_code): - note = 'pidfile locking' + note.append('pidfile locking') # Test results should be in complete state elif state != 'complete': - note = f"beakerlib: State '{state}'" + note.append(f"beakerlib: State '{state}'") # Finally we have a valid result else: actual_result = ResultOutcome.from_spec(result.lower()) diff --git a/tmt/frameworks/shell.py b/tmt/frameworks/shell.py index d5042f3fe4..785f5735da 100644 --- a/tmt/frameworks/shell.py +++ b/tmt/frameworks/shell.py @@ -85,7 +85,7 @@ def _process_results_reduce( invocation=invocation, result=actual_outcome, log=test_logs, - note=note, + note=[note] if note else [], subresult=[result.to_subresult() for result in results])] @classmethod @@ -108,7 +108,7 @@ def extract_results( :returns: list of results. """ assert invocation.return_code is not None - note = None + note: list[str] = [] # Handle the `tmt-report-result` command results as a single test with assigned tmt # subresults. @@ -124,11 +124,11 @@ def extract_results( result = ResultOutcome.ERROR # Add note about the exceeded duration if invocation.return_code == tmt.utils.ProcessExitCodes.TIMEOUT: - note = 'timeout' + note.append('timeout') invocation.phase.timeout_hint(invocation) elif tmt.utils.ProcessExitCodes.is_pidfile(invocation.return_code): - note = 'pidfile locking' + note.append('pidfile locking') return [tmt.Result.from_test_invocation( invocation=invocation, diff --git a/tmt/result.py b/tmt/result.py index be495387b4..2b94cc42de 100644 --- a/tmt/result.py +++ b/tmt/result.py @@ -176,7 +176,9 @@ class BaseResult(SerializableContainer): serialize=lambda result: result.value, unserialize=ResultOutcome.from_spec ) - note: Optional[str] = None + note: list[str] = field( + default_factory=cast(Callable[[], list[str]], list), + unserialize=lambda value: [] if value is None else value) log: list[Path] = field( default_factory=cast(Callable[[], list[Path]], list), serialize=lambda logs: [str(log) for log in logs], @@ -200,10 +202,14 @@ def show(self) -> str: ] if self.note: - components.append(f'({self.note})') + components.append(f'({self.printable_note})') return ' '.join(components) + @property + def printable_note(self) -> str: + return ', '.join(self.note) + @dataclasses.dataclass class CheckResult(BaseResult): @@ -297,7 +303,7 @@ def from_test_invocation( *, invocation: 'tmt.steps.execute.TestInvocation', result: ResultOutcome, - note: Optional[str] = None, + note: Optional[list[str]] = None, ids: Optional[ResultIds] = None, log: Optional[list[Path]] = None, subresult: Optional[list[SubResult]] = None) -> 'Result': @@ -341,7 +347,7 @@ def from_test_invocation( fmf_id=invocation.test.fmf_id, context=invocation.phase.step.plan._fmf_context, result=result, - note=note, + note=note or [], start_time=invocation.start_time, end_time=invocation.end_time, duration=invocation.real_duration, @@ -356,13 +362,6 @@ def from_test_invocation( return _result.interpret_result(invocation.test.result, interpret_checks) - def append_note(self, note: str) -> None: - """ Append text to result note """ - if self.note: - self.note += f", {note}" - else: - self.note = note - def interpret_check_result( self, check_name: str, @@ -385,21 +384,21 @@ def interpret_check_result( if interpret == CheckResultInterpret.RESPECT: if interpreted_outcome == ResultOutcome.FAIL: - self.append_note(f"check '{check_name}' failed") + self.note.append(f"check '{check_name}' failed") elif interpret == CheckResultInterpret.INFO: interpreted_outcome = ResultOutcome.INFO - self.append_note(f"check '{check_name}' is informational") + self.note.append(f"check '{check_name}' is informational") elif interpret == CheckResultInterpret.XFAIL: if reduced_outcome == ResultOutcome.PASS: interpreted_outcome = ResultOutcome.FAIL - self.append_note(f"check '{check_name}' did not fail as expected") + self.note.append(f"check '{check_name}' did not fail as expected") if reduced_outcome == ResultOutcome.FAIL: interpreted_outcome = ResultOutcome.PASS - self.append_note(f"check '{check_name}' failed as expected") + self.note.append(f"check '{check_name}' failed as expected") return interpreted_outcome @@ -437,11 +436,11 @@ def interpret_result( # Override result with result outcome provided by user if interpret not in (ResultInterpret.RESPECT, ResultInterpret.XFAIL): self.result = ResultOutcome(interpret.value) - self.append_note(f"test result overridden: {self.result.value}") + self.note.append(f"test result overridden: {self.result.value}") # Add original result to note if the result has changed if self.result != self.original_result: - self.append_note(f"original test result: {self.original_result.value}") + self.note.append(f"original test result: {self.original_result.value}") return self @@ -450,15 +449,15 @@ def interpret_result( if self.result == ResultOutcome.PASS: self.result = ResultOutcome.FAIL - self.append_note("test was expected to fail") + self.note.append("test was expected to fail") elif self.result == ResultOutcome.FAIL: self.result = ResultOutcome.PASS - self.append_note("test failed as expected") + self.note.append("test failed as expected") # Add original result to note if the result has changed if self.result != self.original_result: - self.append_note(f"original test result: {self.original_result.value}") + self.note.append(f"original test result: {self.original_result.value}") return self @@ -522,7 +521,7 @@ def show(self, display_guest: bool = True) -> str: components.append(f'(on {format_guest_full_name(self.guest.name, self.guest.role)})') if self.note: - components.append(f'({self.note})') + components.append(f'({self.printable_note})') return ' '.join(components) diff --git a/tmt/schemas/common.yaml b/tmt/schemas/common.yaml index 5176c1a93d..5d13b295a8 100644 --- a/tmt/schemas/common.yaml +++ b/tmt/schemas/common.yaml @@ -492,7 +492,9 @@ definitions: - restraint result_note: - type: string + type: array + items: + type: string result_log: type: array diff --git a/tmt/steps/execute/__init__.py b/tmt/steps/execute/__init__.py index 103af4fced..f38263eeff 100644 --- a/tmt/steps/execute/__init__.py +++ b/tmt/steps/execute/__init__.py @@ -824,10 +824,7 @@ def _process_results_partials( else: if not partial_result.name.startswith('/'): - if partial_result.note and isinstance(partial_result.note, str): - partial_result.note += ", custom test result name should start with '/'" - else: - partial_result.note = "custom test result name should start with '/'" + partial_result.note.append("custom test result name should start with '/'") partial_result.name = '/' + partial_result.name partial_result.name = test.name + partial_result.name @@ -878,13 +875,13 @@ def extract_custom_results(self, invocation: TestInvocation) -> list["tmt.Result if not collection.file_exists: return [tmt.Result.from_test_invocation( invocation=invocation, - note=f"custom results file not found in '{invocation.test_data_path}'", + note=[f"custom results file not found in '{invocation.test_data_path}'"], result=ResultOutcome.ERROR)] if not collection.results: return [tmt.Result.from_test_invocation( invocation=invocation, - note="no custom results were provided", + note=["no custom results were provided"], result=ResultOutcome.ERROR)] collection.validate() diff --git a/tmt/steps/execute/internal.py b/tmt/steps/execute/internal.py index 92a5cd4aed..a7f2a52ec3 100644 --- a/tmt/steps/execute/internal.py +++ b/tmt/steps/execute/internal.py @@ -561,13 +561,14 @@ def _run_tests( except tmt.utils.RebootTimeoutError: for result in invocation.results: result.result = ResultOutcome.ERROR - result.note = 'reboot timeout' + result.note.append('reboot timeout') else: for result in invocation.results: result.result = ResultOutcome.ERROR - result.note = ('crashed too many times, ' - 'you may want to set restart-max-count larger') + result.note.append( + 'crashed too many times, ' + 'you may want to set restart-max-count larger') # Handle reboot, abort, exit-first if invocation.reboot_requested: @@ -580,12 +581,12 @@ def _run_tests( except tmt.utils.RebootTimeoutError: for result in invocation.results: result.result = ResultOutcome.ERROR - result.note = 'reboot timeout' + result.note.append('reboot timeout') if invocation.abort_requested: for result in invocation.results: # In case of aborted all results in list will be aborted - result.note = 'aborted' + result.note.append('aborted') self._results.extend(invocation.results) diff --git a/tmt/steps/report/html/template.html.j2 b/tmt/steps/report/html/template.html.j2 index bb65387806..82d2779753 100644 --- a/tmt/steps/report/html/template.html.j2 +++ b/tmt/steps/report/html/template.html.j2 @@ -117,6 +117,11 @@ width: 22ex; } + #results ul { + margin: 0px; + padding-inline-start: 16px; + } + .context-dimension { display: inline-block; text-align: center; @@ -266,7 +271,17 @@ Context: {% endfor %} data - {% if result.note %}{{ result.note | e }}{% else %}-{% endif %} + + {% if result.note %} + + {% else %} + - + {% endif %} + {% if result.check %}