diff --git a/robotframework_reportportal/listener.py b/robotframework_reportportal/listener.py index fc195e7..25b5b4c 100644 --- a/robotframework_reportportal/listener.py +++ b/robotframework_reportportal/listener.py @@ -27,7 +27,7 @@ from reportportal_client.helpers import LifoQueue, guess_content_type_from_bytes, is_binary from robotframework_reportportal.helpers import _unescape, match_pattern, translate_glob_to_regex -from robotframework_reportportal.model import Keyword, Launch, LogMessage, Suite, Test +from robotframework_reportportal.model import Keyword, Launch, LogMessage, Suite, Test, Entity from robotframework_reportportal.service import RobotService from robotframework_reportportal.static import MAIN_SUITE_ID, PABOT_WITHOUT_LAUNCH_ID_MSG from robotframework_reportportal.variables import Variables @@ -150,8 +150,7 @@ def _build_msg_struct(self, message: Dict[str, Any]) -> LogMessage: else: msg = LogMessage(message["message"]) msg.level = message["level"] - if not msg.launch_log: - msg.item_id = getattr(self.current_item, "rp_item_id", None) + msg.item_id = self.current_item.rp_item_id message_str = msg.message if is_binary(message_str): @@ -218,42 +217,52 @@ def __post_log_message(self, message: LogMessage) -> None: logger.debug(f"ReportPortal - Log Message: {message}") self.service.log(message=message) - def __post_skipped_keyword(self, kwd: Keyword) -> None: + def __post_skipped_keyword(self, kwd: Keyword, clean_data_remove: bool) -> None: self._do_start_keyword(kwd) + if clean_data_remove: + kwd.remove_data = False for log_message in kwd.skipped_logs: self.__post_log_message(log_message) - skipped_kwds = kwd.skipped_keywords + skipped_keywords = kwd.skipped_keywords kwd.skipped_keywords = [] - for skipped_kwd in skipped_kwds: - self.__post_skipped_keyword(skipped_kwd) - self._do_end_keyword(kwd) + for skipped_kwd in skipped_keywords: + self.__post_skipped_keyword(skipped_kwd, clean_data_remove) + if kwd.status != "NOT SET": + self._do_end_keyword(kwd) - def _post_skipped_keywords(self, to_post: Optional[Any]) -> None: + def _post_skipped_keywords(self, to_post: Optional[Any], clean_data_remove: bool = False) -> None: if not to_post: return if isinstance(to_post, Keyword): if not to_post.posted: self._do_start_keyword(to_post) + if clean_data_remove: + to_post.remove_data = False log_messages = to_post.skipped_logs to_post.skipped_logs = [] for log_message in log_messages: self.__post_log_message(log_message) - skipped_kwds = to_post.skipped_keywords - if skipped_kwds: + skipped_keywords = to_post.skipped_keywords + if skipped_keywords: to_post.skipped_keywords = [] - for skipped_kwd in skipped_kwds: + for skipped_kwd in skipped_keywords: if skipped_kwd.posted: log_messages = skipped_kwd.skipped_logs skipped_kwd.skipped_logs = [] for log_message in log_messages: self.__post_log_message(log_message) - skipped_child_kwds = skipped_kwd.skipped_keywords - for skipped_child_kwd in skipped_child_kwds: + skipped_child_keywords = skipped_kwd.skipped_keywords + for skipped_child_kwd in skipped_child_keywords: if skipped_child_kwd.posted: continue - self.__post_skipped_keyword(skipped_child_kwd) + self.__post_skipped_keyword(skipped_child_kwd, clean_data_remove) continue - self.__post_skipped_keyword(skipped_kwd) + self.__post_skipped_keyword(skipped_kwd, clean_data_remove) + + def __find_root_keyword_with_removed_data(self, keyword: Entity) -> Entity: + if keyword.parent.remove_data and keyword.parent.type == "KEYWORD": + return self.__find_root_keyword_with_removed_data(keyword.parent) + return keyword def _log_message(self, message: LogMessage) -> None: """Send log message to the Report Portal. @@ -274,10 +283,8 @@ def _log_message(self, message: LogMessage) -> None: else: if not self._remove_all_keyword_content: # Post everything skipped by '--removekeywords' option - self._post_skipped_keywords(current_item) + self._post_skipped_keywords(self.__find_root_keyword_with_removed_data(current_item), True) self.__post_log_message(message) - else: - self.current_item.skipped_logs.append(message) @check_rp_enabled def log_message(self, message: Dict) -> None: @@ -304,11 +311,6 @@ def log_message_with_image(self, msg: Dict, image: str): } self._log_message(mes) - @property - def parent_id(self) -> Optional[str]: - """Get rp_item_id attribute of the current item.""" - return getattr(self.current_item, "rp_item_id", None) - @property def service(self) -> RobotService: """Initialize instance of the RobotService.""" @@ -409,8 +411,7 @@ def start_suite(self, name: str, attributes: Dict, ts: Optional[Any] = None) -> logger.debug(f"ReportPortal - Create global Suite: {attributes}") else: logger.debug(f"ReportPortal - Start Suite: {attributes}") - suite = Suite(name, attributes) - suite.rp_parent_item_id = self.parent_id + suite = Suite(name, attributes, self.current_item) suite.rp_item_id = self.service.start_suite(suite=suite, ts=ts) self._add_current_item(suite) @@ -440,9 +441,8 @@ def start_test(self, name: str, attributes: Dict, ts: Optional[Any] = None) -> N # no 'source' parameter at this level for Robot versions < 4 attributes = attributes.copy() attributes["source"] = getattr(self.current_item, "source", None) - test = Test(name=name, robot_attributes=attributes, test_attributes=self.variables.test_attributes) + test = Test(name, attributes, self.variables.test_attributes, self.current_item) logger.debug(f"ReportPortal - Start Test: {attributes}") - test.rp_parent_item_id = self.parent_id test.rp_item_id = self.service.start_test(test=test, ts=ts) self._add_current_item(test) @@ -489,9 +489,8 @@ def start_keyword(self, name: str, attributes: Dict, ts: Optional[Any] = None) - :param attributes: Dictionary passed by the Robot Framework :param ts: Timestamp(used by the ResultVisitor) """ - kwd = Keyword(name=name, parent_type=self.current_item.type, robot_attributes=attributes) + kwd = Keyword(name, attributes, self.current_item) parent = self.current_item - kwd.rp_parent_item_id = parent.rp_item_id skip_kwd = parent.remove_data skip_data = self._remove_all_keyword_content or self._remove_data_passed_tests kwd.remove_data = skip_kwd or skip_data @@ -535,19 +534,19 @@ def end_keyword(self, _: Optional[str], attributes: Dict, ts: Optional[Any] = No kwd = self.current_item.update(attributes) if kwd.matched_filter is WUKS_KEYWORD_MATCH and kwd.skip_origin is kwd: - skipped_kwds = kwd.skipped_keywords - skipped_kwds_num = len(skipped_kwds) - if skipped_kwds_num > 2: + skipped_keywords = kwd.skipped_keywords + skipped_keywords_num = len(skipped_keywords) + if skipped_keywords_num > 2: if kwd.status == "FAIL": message = REMOVED_WUKS_KEYWORD_LOG.format(number=len(kwd.skipped_keywords) - 1) else: message = REMOVED_WUKS_KEYWORD_LOG.format(number=len(kwd.skipped_keywords) - 2) self._log_data_removed(kwd.rp_item_id, kwd.start_time, message) - if skipped_kwds_num > 1 and kwd.status != "FAIL": + if skipped_keywords_num > 1 and kwd.status != "FAIL": first_iteration = kwd.skipped_keywords[0] self._post_skipped_keywords(first_iteration) self._do_end_keyword(first_iteration) - if skipped_kwds_num > 0: + if skipped_keywords_num > 0: last_iteration = kwd.skipped_keywords[-1] self._post_skipped_keywords(last_iteration) self._do_end_keyword(last_iteration, ts) @@ -555,11 +554,13 @@ def end_keyword(self, _: Optional[str], attributes: Dict, ts: Optional[Any] = No elif ( (kwd.matched_filter is FOR_KEYWORD_MATCH) or (kwd.matched_filter is WHILE_KEYWORD_NAME) ) and kwd.skip_origin is kwd: - skipped_kwds = kwd.skipped_keywords - skipped_kwds_num = len(skipped_kwds) - if skipped_kwds_num > 1: + skipped_keywords = kwd.skipped_keywords + skipped_keywords_num = len(skipped_keywords) + if skipped_keywords_num > 1: self._log_data_removed( - kwd.rp_item_id, kwd.start_time, REMOVED_FOR_WHILE_KEYWORD_LOG.format(number=skipped_kwds_num - 1) + kwd.rp_item_id, + kwd.start_time, + REMOVED_FOR_WHILE_KEYWORD_LOG.format(number=skipped_keywords_num - 1), ) last_iteration = kwd.skipped_keywords[-1] self._post_skipped_keywords(last_iteration) diff --git a/robotframework_reportportal/model.py b/robotframework_reportportal/model.py index a189d32..58c3f79 100644 --- a/robotframework_reportportal/model.py +++ b/robotframework_reportportal/model.py @@ -24,6 +24,31 @@ TEST_CASE_ID_SIGN = "test_case_id:" +class Entity: + """Base class for all test items.""" + + type: str + remove_data: bool + rp_item_id: Optional[str] + parent: Optional["Entity"] + + def __init__(self, entity_type: str, parent: Optional["Entity"]): + """Initialize required attributes. + + :param entity_type: Type of the entity + :param parent: Parent entity + """ + self.type = entity_type + self.parent = parent + self.rp_item_id = None + self.remove_data = False + + @property + def rp_parent_item_id(self): + """Get parent item ID.""" + return getattr(self.parent, "rp_item_id", None) + + class LogMessage(str): """Class represents Robot Framework messages.""" @@ -44,7 +69,7 @@ def __init__(self, message: str): self.timestamp = None -class Keyword: +class Keyword(Entity): """Class represents Robot Framework keyword.""" robot_attributes: Dict[str, Any] @@ -56,9 +81,6 @@ class Keyword: keyword_type: str libname: str name: str - rp_item_id: Optional[str] - rp_parent_item_id: Optional[str] - parent_type: str start_time: str status: str tags: List[str] @@ -70,13 +92,14 @@ class Keyword: matched_filter: Optional[Any] skip_origin: Optional[Any] - def __init__(self, name: str, robot_attributes: Dict[str, Any], parent_type: Optional[str] = None): + def __init__(self, name: str, robot_attributes: Dict[str, Any], parent: Entity): """Initialize required attributes. :param name: Name of the keyword :param robot_attributes: Attributes passed through the listener - :param parent_type: Type of the parent test item + :param parent: Parent entity """ + super().__init__("KEYWORD", parent) self.robot_attributes = robot_attributes self.args = robot_attributes["args"] self.assign = robot_attributes["assign"] @@ -86,9 +109,6 @@ def __init__(self, name: str, robot_attributes: Dict[str, Any], parent_type: Opt self.keyword_type = robot_attributes["type"] self.libname = robot_attributes["libname"] self.name = name - self.rp_item_id = None - self.rp_parent_item_id = None - self.parent_type = parent_type self.start_time = robot_attributes["starttime"] self.status = robot_attributes.get("status") self.tags = robot_attributes["tags"] @@ -96,7 +116,6 @@ def __init__(self, name: str, robot_attributes: Dict[str, Any], parent_type: Opt self.skipped_keywords = [] self.skipped_logs = [] self.posted = True - self.remove_data = False self.matched_filter = None self.skip_origin = None @@ -111,12 +130,12 @@ def get_name(self) -> str: def get_type(self) -> str: """Get keyword type.""" if self.keyword_type.lower() in ("setup", "teardown"): - if self.parent_type.lower() == "keyword": + if self.parent.type.lower() == "keyword": return "STEP" if self.keyword_type.lower() == "setup": - return "BEFORE_{0}".format(self.parent_type.upper()) + return "BEFORE_{0}".format(self.parent.type.upper()) if self.keyword_type.lower() == "teardown": - return "AFTER_{0}".format(self.parent_type.upper()) + return "AFTER_{0}".format(self.parent.type.upper()) else: return "STEP" @@ -130,7 +149,7 @@ def update(self, attributes: Dict[str, Any]) -> "Keyword": return self -class Suite: +class Suite(Entity): """Class represents Robot Framework test suite.""" robot_attributes: Union[List[str], Dict[str, Any]] @@ -141,23 +160,21 @@ class Suite: metadata: Dict[str, str] name: str robot_id: str - rp_item_id: Optional[str] - rp_parent_item_id: Optional[str] start_time: Optional[str] statistics: str status: str suites: List[str] tests: List[str] total_tests: int - type: str = "SUITE" - remove_data: bool = False - def __init__(self, name: str, robot_attributes: Dict[str, Any]): + def __init__(self, name: str, robot_attributes: Dict[str, Any], parent: Optional[Entity] = None): """Initialize required attributes. :param name: Suite name :param robot_attributes: Suite attributes passed through the listener + :param parent: Parent entity """ + super().__init__("SUITE", parent) self.robot_attributes = robot_attributes self.doc = robot_markup_to_markdown(robot_attributes["doc"]) self.end_time = robot_attributes.get("endtime", "") @@ -166,15 +183,12 @@ def __init__(self, name: str, robot_attributes: Dict[str, Any]): self.metadata = robot_attributes["metadata"] self.name = name self.robot_id = robot_attributes["id"] - self.rp_item_id = None - self.rp_parent_item_id = None self.start_time = robot_attributes.get("starttime") self.statistics = robot_attributes.get("statistics") self.status = robot_attributes.get("status") self.suites = robot_attributes["suites"] self.tests = robot_attributes["tests"] self.total_tests = robot_attributes["totaltests"] - self.type = "SUITE" @property def attributes(self) -> Optional[List[Dict[str, str]]]: @@ -224,7 +238,7 @@ def attributes(self) -> Optional[List[Dict[str, str]]]: return self.launch_attributes -class Test: +class Test(Entity): """Class represents Robot Framework test case.""" _critical: str @@ -237,21 +251,18 @@ class Test: message: str name: str robot_id: str - rp_item_id: Optional[str] - rp_parent_item_id: Optional[str] start_time: str status: str template: str - type: str = "TEST" skipped_keywords: List[Keyword] - remove_data: bool = False - def __init__(self, name: str, robot_attributes: Dict[str, Any], test_attributes: List[str]): + def __init__(self, name: str, robot_attributes: Dict[str, Any], test_attributes: List[str], parent: Entity): """Initialize required attributes. :param name: Name of the test :param robot_attributes: Attributes passed through the listener """ + super().__init__("TEST", parent) # for backward compatibility with Robot < 4.0 mark every test case # as critical if not set self._critical = robot_attributes.get("critical", "yes") @@ -264,12 +275,9 @@ def __init__(self, name: str, robot_attributes: Dict[str, Any], test_attributes: self.message = robot_attributes.get("message") self.name = name self.robot_id = robot_attributes["id"] - self.rp_item_id = None - self.rp_parent_item_id = None self.start_time = robot_attributes["starttime"] self.status = robot_attributes.get("status") self.template = robot_attributes["template"] - self.type = "TEST" self.skipped_keywords = [] @property diff --git a/robotframework_reportportal/service.py b/robotframework_reportportal/service.py index 4028d58..b0763ac 100644 --- a/robotframework_reportportal/service.py +++ b/robotframework_reportportal/service.py @@ -269,7 +269,7 @@ def log(self, message: LogMessage, ts: Optional[str] = None): """ sl_rq = { "attachment": message.attachment, - "item_id": message.item_id, + "item_id": None if message.launch_log else message.item_id, "level": LOG_LEVEL_MAPPING.get(message.level, "INFO"), "message": message.message, "time": ts or to_epoch(message.timestamp) or timestamp(), diff --git a/robotframework_reportportal/static.py b/robotframework_reportportal/static.py index 5d6d9af..2f3aec6 100644 --- a/robotframework_reportportal/static.py +++ b/robotframework_reportportal/static.py @@ -31,4 +31,9 @@ "Pabot library is used but RP_LAUNCH_UUID was not provided. Please, " "initialize listener with the RP_LAUNCH_UUID argument." ) -STATUS_MAPPING: Dict[str, str] = {"PASS": "PASSED", "FAIL": "FAILED", "NOT RUN": "SKIPPED", "SKIP": "SKIPPED"} +STATUS_MAPPING: Dict[str, str] = { + "PASS": "PASSED", + "FAIL": "FAILED", + "NOT RUN": "SKIPPED", + "SKIP": "SKIPPED", +} diff --git a/tests/integration/test_remove_keywords.py b/tests/integration/test_remove_keywords.py index 29bc147..62ab6e0 100644 --- a/tests/integration/test_remove_keywords.py +++ b/tests/integration/test_remove_keywords.py @@ -101,6 +101,21 @@ def test_remove_keyword_not_provided(mock_client_init): 0, "2 failing items removed using the --remove-keywords option.", ), + ( + "examples/wuks_keyword_warnings.robot", + "WUKS", + 0, + ["PASSED"] * 3 + + ["FAILED"] * 3 + + ["PASSED"] * 3 + + ["FAILED"] * 3 + + ["PASSED"] * 2 + + ["SKIPPED"] * 3 + + ["PASSED"] * 4, + 10, + 6, + "To less executions warning", + ), ( "examples/rkie_keyword.robot", "ALL", @@ -242,10 +257,19 @@ def test_remove_keyword_not_provided(mock_client_init): 0, "Content removed using the --remove-keywords option.", ), + ( + "examples/binary_file_log_as_text.robot", + "tag:binary", + 0, + ["PASSED"] * 5, + 3, + 2, + 'Binary data of type "image/jpeg" logging skipped, as it was processed as text and hence corrupted.', + ), ], ) @mock.patch(REPORT_PORTAL_SERVICE) -def test_for_and_while_keyword_remove( +def test_keyword_remove( mock_client_init, file, keyword_to_remove, exit_code, expected_statuses, log_number, skip_idx, skip_message ): mock_client = mock_client_init.return_value