diff --git a/docs/configuration/overview.md b/docs/configuration/overview.md index 91835b6..b2e0c5e 100644 --- a/docs/configuration/overview.md +++ b/docs/configuration/overview.md @@ -89,6 +89,7 @@ file. This is noted per option below. | [`attachments-variable`](#attachments-variable) | — | ✅ | G | variable name (`ITB_ATTACHMENTS_DIR`) | | [`keep-extracted-report`](#keep-extracted-report) | — | ✅ | G · F | bool (`false`) | | [`merge-protocol`](#merge-protocol) | `--no-merge-protocol` | ✅ | F | bool (`true`) | +| [`set-tester-from-report-creator`](#set-tester-from-report-creator) | `--set-tester-from-report-creator` | ✅ | F | bool (`false`) | | [`keyword-comment-style`](#keyword-comment-style) | — | ✅ | F | `STRUCTURED` \| `FLAT` (`STRUCTURED`) | | [`keyword-comment-max-depth`](#keyword-comment-max-depth) | — | ✅ | F | int (`5`) | | [`keyword-comment-max-rows`](#keyword-comment-max-rows) | — | ✅ | F | int, `0` = no limit (`300`) | @@ -535,6 +536,29 @@ testbench2robotframework fetch-results --no-merge-protocol output.xml my_report. merge-protocol = true ``` +### `set-tester-from-report-creator` + +Sets the user who created the TestBench report as **tester** of the test cases the +current Robot run executed. The user is read from `reportCreation.creator` in the +report's `manifest.json`: its `userKey` becomes the `testerKey` of the test case in +`protocol.json`, and its `userKey` and `userName` become `exec.tester` in the test +case's JSON file. An existing tester is overwritten. + +Only test cases with the verdict `Pass` or `Fail` get a tester. Skipped and not run +tests, and executions kept from the input protocol when merging, stay unchanged. If +`manifest.json` is missing or names no creator, a warning is logged and no tester is +set. + +- **Values:** `true` / `false`. Default: `false`. +- **CLI:** `--set-tester-from-report-creator` (turns it on). + +```bash +testbench2robotframework fetch-results --set-tester-from-report-creator output.xml my_report.zip +``` +```toml +set-tester-from-report-creator = true +``` + ### `keyword-comment-style` How the execution comment of a keyword is rendered. `STRUCTURED` shows the whole diff --git a/docs/usage/fetch_results.md b/docs/usage/fetch_results.md index 59d85d9..01de1cb 100644 --- a/docs/usage/fetch_results.md +++ b/docs/usage/fetch_results.md @@ -114,6 +114,13 @@ second run starts over. To combine several Robot runs, merge the output XMLs fir (`rebot --merge output1.xml output2.xml`) and call `fetch-results` once. ::: +## Tester + +The results do not name a tester by default. With +[`set-tester-from-report-creator` / `--set-tester-from-report-creator`](../configuration/overview.md#set-tester-from-report-creator) +the user who created the TestBench report becomes the tester of every test case the +run executed with the verdict `Pass` or `Fail`. + --- ## Execution comments diff --git a/testbench2robotframework/__init__.py b/testbench2robotframework/__init__.py index d4d9106..8b9eda8 100644 --- a/testbench2robotframework/__init__.py +++ b/testbench2robotframework/__init__.py @@ -17,4 +17,4 @@ from .testbench2robotframework import testbench2robotframework # noqa: F401 -__version__ = "2.0.1rc1" +__version__ = "2.1.0rc1" diff --git a/testbench2robotframework/cli.py b/testbench2robotframework/cli.py index 26241c7..a061874 100644 --- a/testbench2robotframework/cli.py +++ b/testbench2robotframework/cli.py @@ -267,6 +267,12 @@ def generate_tests( # noqa: PLR0913 help="""Overwrite the main protocol instead of merging the Robot Framework results into the protocol.json that is already part of the TestBench report.""", ) +@click.option( + "--set-tester-from-report-creator", + is_flag=True, + help="""Set the user who created the TestBench report (manifest.json) as tester of + every test case that this run executed with the verdict Pass or Fail.""", +) @click.argument("robot-result", type=click.Path(path_type=Path)) @click.argument("testbench-report", type=click.Path(path_type=Path)) def fetch_results( @@ -275,6 +281,7 @@ def fetch_results( output_directory: Path, testbench_report: Path, no_merge_protocol: bool, + set_tester_from_report_creator: bool, ): """ Fetch Robot Framework execution results from and save to a . @@ -284,6 +291,8 @@ def fetch_results( configuration["merge-protocol"] = False else: configuration["merge-protocol"] = configuration.get("merge-protocol", True) + if set_tester_from_report_creator: + configuration["set-tester-from-report-creator"] = True robot2testbench(testbench_report, robot_result, output_directory, configuration) diff --git a/testbench2robotframework/config.py b/testbench2robotframework/config.py index 66eac29..b5173d4 100644 --- a/testbench2robotframework/config.py +++ b/testbench2robotframework/config.py @@ -237,6 +237,7 @@ class Configuration: _: KW_ONLY include_blocked: bool = False merge_protocol: bool = True + set_tester_from_report_creator: bool = False keep_extracted_report: bool = False attachments_directory: str = "" attachments_variable: str = "ITB_ATTACHMENTS_DIR" @@ -274,6 +275,9 @@ def from_dict(cls, dictionary) -> Configuration: } ), merge_protocol=dictionary.get("merge-protocol", True), + set_tester_from_report_creator=dictionary.get( + "set-tester-from-report-creator", False + ), keep_extracted_report=dictionary.get("keep-extracted-report", False), attachments_directory=dictionary.get("attachments-directory", "").replace("\\", "/"), attachments_variable=dictionary.get("attachments-variable", "ITB_ATTACHMENTS_DIR"), diff --git a/testbench2robotframework/result_writer.py b/testbench2robotframework/result_writer.py index 94e30a2..6b10f11 100644 --- a/testbench2robotframework/result_writer.py +++ b/testbench2robotframework/result_writer.py @@ -1,4 +1,5 @@ import html +import json import os import re import shutil @@ -40,6 +41,7 @@ TestCaseNode, TestCaseSetExecutionForImport, TestCaseSetNode, + UserInfo, UserReference, VerdictStatus, ) @@ -52,7 +54,7 @@ index_by_test_case_set_key, merge_test_case_executions, ) -from .utils import directory_to_zip, open_report +from .utils import directory_to_zip, open_report, read_manifest_json_from_testbench_report try: from robot.result import Group @@ -200,6 +202,32 @@ def unexecuted_table_row(unique_id: str, verdict) -> TestCaseRow: ) +def read_report_creator(json_dir: str | Path) -> UserInfo | None: + """The user who created the report, as named in its 'manifest.json'. + + Returns None, and warns, when the manifest is missing, unreadable or names no creator. + """ + try: + manifest = read_manifest_json_from_testbench_report(Path(json_dir)) + except (FileNotFoundError, json.JSONDecodeError) as e: + logger.warning(f"Could not read the report's manifest.json, no tester is set: {e}") + return None + report_creation = manifest.get("reportCreation") if isinstance(manifest, dict) else None + creator = (report_creation or {}).get("creator") or {} + user_key = creator.get("userKey") + if user_key in (None, ""): + logger.warning( + "The report's manifest.json names no creator (reportCreation.creator.userKey), " + "no tester is set." + ) + return None + return UserInfo( + userKey=str(user_key), + userLogin=creator.get("userLogin", ""), + userName=creator.get("userName", ""), + ) + + class ResultWriter(ResultVisitor): def __init__( self, @@ -243,6 +271,9 @@ def __init__( ) self.base_protocol_by_key = index_by_test_case_set_key(self.base_protocol) self.merged_verdicts: dict[str, Verdict] = {} + self.report_creator: UserInfo | None = ( + read_report_creator(self.json_dir) if config.set_tester_from_report_creator else None + ) self.keyword_comment_style = config.keyword_comment_style self.keyword_comment_renderer = KeywordCommentRenderer( max_depth=config.keyword_comment_max_depth, @@ -319,6 +350,7 @@ def end_test(self, test: TestCase): step.exec = _empty_keyword_call_execution() step.exec.verdict = KeywordVerdict.Skipped self._set_itb_testcase_execution_result(itb_test_case, self.test_chain) + self._set_itb_testcase_tester(itb_test_case) self._set_itb_testcase_execution_comment(itb_test_case, self.test_chain) self._set_itb_testcase_references(itb_test_case, self.test_chain) except TypeError as e: @@ -445,6 +477,17 @@ def _set_itb_testcase_execution_result(self, itb_test_case: TestCaseDetails, tes protocol_result = self._set_itb_test_case_status(itb_test_case, "undef") self.protocol_test_case.result = protocol_result + def _set_itb_testcase_tester(self, itb_test_case: TestCaseDetails): + """Makes the report's creator the tester of a test case this run performed.""" + if self.report_creator is None or not itb_test_case.exec: + return + if self.protocol_test_case.result.status != ActivityStatus.Performed: + return + self.protocol_test_case.testerKey = self.report_creator.userKey + itb_test_case.exec.tester = UserReference( + key=self.report_creator.userKey, name=self.report_creator.userName + ) + def _get_test_phase_body(self, test_phase: TestCase) -> list[Keyword]: return self._get_keywords_from_rf_body(test_phase) diff --git a/testbench2robotframework/robotframework2testbench.py b/testbench2robotframework/robotframework2testbench.py index 2ffe1b7..c1cff2b 100644 --- a/testbench2robotframework/robotframework2testbench.py +++ b/testbench2robotframework/robotframework2testbench.py @@ -14,7 +14,7 @@ def robot2testbench( json_input_report: str | Path, robot_result_xml: str | Path, json_output_result: str | Path | None = None, - config: dict | None = None, + config: dict | Configuration | None = None, ): if not Path(json_input_report).exists(): sys.exit("No TestBench report found at the given path: expected a directory or a ZIP file.") @@ -22,7 +22,9 @@ def robot2testbench( sys.exit("The Robot Framework result XML does not exist at the given path.") perform_version_check(Path(json_input_report)) - configuration = Configuration.from_dict(config) + configuration = ( + config if isinstance(config, Configuration) else Configuration.from_dict(config or {}) + ) setup_logger(configuration) logger.debug("Configuration loaded.") result = ExecutionResult(robot_result_xml) diff --git a/testbench2robotframework/testbench2robotframework.py b/testbench2robotframework/testbench2robotframework.py index d951478..f94f2ed 100644 --- a/testbench2robotframework/testbench2robotframework.py +++ b/testbench2robotframework/testbench2robotframework.py @@ -11,7 +11,9 @@ def testbench2robotframework(testbench_report: str | Path, config: dict | Configuration): perform_version_check(Path(testbench_report)) - configuration = Configuration.from_dict(config) if isinstance(config, dict) else config + configuration = ( + config if isinstance(config, Configuration) else Configuration.from_dict(config or {}) + ) setup_logger(configuration) logger.debug("Configuration loaded.") with open_report(Path(testbench_report), configuration.keep_extracted_report) as report: diff --git a/tests/test_report_creator_tester.py b/tests/test_report_creator_tester.py new file mode 100644 index 0000000..73196b0 --- /dev/null +++ b/tests/test_report_creator_tester.py @@ -0,0 +1,234 @@ +"""'set-tester-from-report-creator' makes the report's creator the tester of executed test cases. + +The creator is read from 'reportCreation.creator' in the report's 'manifest.json'. +Only test cases this run actually performed (verdict Pass or Fail) get the tester, +in 'protocol.json' ('testerKey') as well as in their test case file ('exec.tester'). +""" + +import json +import logging +from types import SimpleNamespace + +import pytest +from robot.result import TestCase +from test_fetch_results_output import make_report_dir + +from testbench2robotframework import robotframework2testbench +from testbench2robotframework.config import Configuration +from testbench2robotframework.log import logger +from testbench2robotframework.model import ( + ActivityStatus, + ExecStatus, + TestCaseDetails, + TestCaseExecutionDetails, + TestCaseExecutionForImport, + UserInfo, + UserReference, + VerdictStatus, +) +from testbench2robotframework.result_writer import ResultWriter, read_report_creator + +UID = "itb-TC-136099-PC-421880" + +MANIFEST = { + "formatVersion": "1.0", + "serverLocation": {"host": "Salsa.local", "port": 443}, + "reportCreation": { + "creator": {"userKey": "0", "userLogin": "tt-admin", "userName": "Administrator"}, + "startDate": "2026-09-23T15:49:45.845+02:00", + "endDate": "2026-09-23T15:49:45.975+02:00", + "scope": {"projectKey": "50012", "tovKey": "60034", "cycleKey": "40037"}, + "summary": {"testThemesCount": 0, "testCaseSetsCount": 1, "testCasesCount": 3}, + }, + "serverVersions": {"version": "4.1", "databaseVersion": "4.1.01", "revision": "260723/5db9"}, +} + +CREATOR = UserInfo(userKey="0", userLogin="tt-admin", userName="Administrator") + + +def make_itb_test_case(tester=None): + return TestCaseDetails( + uniqueID=UID, + spec=None, + testSequence=[], + parameters=[], + keywords=[], + exec=TestCaseExecutionDetails( + key="79361", + status=ActivityStatus.Planned, + execStatus=ExecStatus.NotBlocked, + verdict=VerdictStatus.Undefined, + plannedDuration=0, + actualDuration=0, + currentUser=UserReference(key="0", name="tt-admin"), + comments="", + defects=[], + udfs=[], + tags=[], + references=[], + tester=tester, + ), + ) + + +def make_result_writer(report_creator): + writer = ResultWriter.__new__(ResultWriter) # bypasses the file system heavy __init__ + writer.report_creator = report_creator + writer.protocol_test_case = TestCaseExecutionForImport(UID, "79361", None, None, None) # type: ignore[arg-type] + return writer + + +def execute(writer, itb_test_case, *statuses): + phases = [TestCase(name=UID, status=status) for status in statuses] + writer._set_itb_testcase_execution_result(itb_test_case, phases) + writer._set_itb_testcase_tester(itb_test_case) + + +@pytest.fixture +def warnings(): + records: list[logging.LogRecord] = [] + + class Collector(logging.Handler): + def emit(self, record): + if record.levelno == logging.WARNING: + records.append(record) + + handler = Collector() + logger.addHandler(handler) + yield records + logger.removeHandler(handler) + + +class ReadReportCreatorTests: + def test_creator_is_read_from_manifest(self, tmp_path): + (tmp_path / "manifest.json").write_text(json.dumps(MANIFEST)) + + assert read_report_creator(tmp_path) == CREATOR + + def test_missing_manifest_warns_and_returns_none(self, tmp_path, warnings): + assert read_report_creator(tmp_path) is None + assert len(warnings) == 1 + + def test_manifest_without_creator_warns_and_returns_none(self, tmp_path, warnings): + (tmp_path / "manifest.json").write_text(json.dumps({"formatVersion": "1.0"})) + + assert read_report_creator(tmp_path) is None + assert len(warnings) == 1 + + def test_creator_without_user_key_warns_and_returns_none(self, tmp_path, warnings): + manifest = {"reportCreation": {"creator": {"userLogin": "tt-admin", "userName": "A"}}} + (tmp_path / "manifest.json").write_text(json.dumps(manifest)) + + assert read_report_creator(tmp_path) is None + assert len(warnings) == 1 + + def test_invalid_manifest_warns_and_returns_none(self, tmp_path, warnings): + (tmp_path / "manifest.json").write_text("{ not json") + + assert read_report_creator(tmp_path) is None + assert len(warnings) == 1 + + +class SetTesterTests: + @pytest.mark.parametrize("statuses", [("PASS",), ("FAIL",), ("PASS", "FAIL")]) + def test_performed_test_case_gets_the_creator_as_tester(self, statuses): + writer = make_result_writer(CREATOR) + itb_test_case = make_itb_test_case() + + execute(writer, itb_test_case, *statuses) + + assert writer.protocol_test_case.testerKey == "0" + assert itb_test_case.exec.tester == UserReference(key="0", name="Administrator") + + @pytest.mark.parametrize("statuses", [("SKIP",), ("NOT RUN",), ("PASS", "SKIP")]) + def test_test_case_not_performed_gets_no_tester(self, statuses): + writer = make_result_writer(CREATOR) + itb_test_case = make_itb_test_case() + + execute(writer, itb_test_case, *statuses) + + assert writer.protocol_test_case.testerKey is None + assert itb_test_case.exec.tester is None + + def test_existing_tester_is_overwritten(self): + writer = make_result_writer(CREATOR) + itb_test_case = make_itb_test_case(tester=UserReference(key="7", name="Neuer Benutzer 2")) + + execute(writer, itb_test_case, "PASS") + + assert itb_test_case.exec.tester == UserReference(key="0", name="Administrator") + + def test_without_creator_nothing_is_set(self): + writer = make_result_writer(None) + existing_tester = UserReference(key="7", name="Neuer Benutzer 2") + itb_test_case = make_itb_test_case(tester=existing_tester) + + execute(writer, itb_test_case, "PASS") + + assert writer.protocol_test_case.testerKey is None + assert itb_test_case.exec.tester == existing_tester + + +class OptionTests: + def test_option_is_off_by_default(self): + assert Configuration.from_dict({}).set_tester_from_report_creator is False + + def test_option_can_be_switched_on(self): + configuration = Configuration.from_dict({"set-tester-from-report-creator": True}) + + assert configuration.set_tester_from_report_creator is True + + +class ResultWriterOptionTests: + @pytest.fixture(autouse=True) + def run_in_tmp_path(self, tmp_path, monkeypatch): + # The writer creates its temporary directories in the current working directory. + monkeypatch.chdir(tmp_path) + + def make_writer(self, tmp_path, manifest, **options): + report = make_report_dir(tmp_path / "report") + (report / "manifest.json").write_text(json.dumps(manifest)) + (tmp_path / "output.xml").write_text("") + cfg = Configuration.from_dict({"merge-protocol": False, **options}) + return ResultWriter(str(report), None, cfg, str(tmp_path / "output.xml")) + + def test_with_option_the_creator_is_read(self, tmp_path): + writer = self.make_writer(tmp_path, MANIFEST, **{"set-tester-from-report-creator": True}) + + assert writer.report_creator == CREATOR + + def test_without_option_the_manifest_is_not_consulted(self, tmp_path, warnings): + writer = self.make_writer(tmp_path, {}) + + assert writer.report_creator is None + assert not [record for record in warnings if "manifest.json" in record.getMessage()] + + +class Robot2TestbenchConfigurationTests: + """Library callers may pass a Configuration instead of a config file dictionary. + + 'dataclasses.asdict' of a Configuration uses field names, which 'from_dict' does + not read - converting it back to a dictionary would silently drop every option. + """ + + def test_configuration_instance_is_used_as_is(self, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + report = make_report_dir(tmp_path / "report") + (report / "manifest.json").write_text(json.dumps(MANIFEST)) + (tmp_path / "output.xml").write_text("") + used = {} + monkeypatch.setattr(robotframework2testbench, "perform_version_check", lambda _: None) + monkeypatch.setattr(robotframework2testbench, "setup_logger", lambda _: None) + monkeypatch.setattr( + robotframework2testbench, + "ResultWriter", + lambda _report, _result, configuration, _xml: used.setdefault("config", configuration), + ) + monkeypatch.setattr( + robotframework2testbench, "ExecutionResult", lambda _: SimpleNamespace(visit=lambda _: None) + ) + configuration = Configuration.from_dict({"set-tester-from-report-creator": True}) + + robotframework2testbench.robot2testbench(report, tmp_path / "output.xml", None, configuration) + + assert used["config"] is configuration