diff --git a/pytest_reportportal/plugin.py b/pytest_reportportal/plugin.py index 6fab6aa1..d7188c6c 100644 --- a/pytest_reportportal/plugin.py +++ b/pytest_reportportal/plugin.py @@ -149,6 +149,10 @@ def pytest_sessionfinish(session: Session) -> None: return config.py_test_service.finish_suites() + + if hasattr(config.py_test_service, 'cleanup_retry_state'): + config.py_test_service.cleanup_retry_state() + if is_control(config): config.py_test_service.finish_launch() @@ -232,7 +236,7 @@ def pytest_runtestloop(session: Session) -> Generator[None, Any, None]: # noinspection PyProtectedMember -@pytest.hookimpl(hookwrapper=True) +@pytest.hookimpl(hookwrapper=True, trylast=True) def pytest_runtest_protocol(item: Item) -> Generator[None, Any, None]: """Control start and finish of pytest items. @@ -271,6 +275,10 @@ def pytest_runtest_protocol(item: Item) -> Generator[None, Any, None]: def pytest_runtest_makereport(item: Item) -> Generator[None, Any, None]: """Change runtest_makereport function. + Enhanced to detect and handle pytest-rerunfailures retry transitions. + Monitors execution_count changes to identify when a test is retried, + allowing each attempt to be reported as a separate item. + :param item: pytest.Item :return: None """ @@ -279,6 +287,7 @@ def pytest_runtest_makereport(item: Item) -> Generator[None, Any, None]: return report = result.get_result() service = item.config.py_test_service + service.handle_retry_transition(item, report) service.process_results(item, report) diff --git a/pytest_reportportal/service.py b/pytest_reportportal/service.py index fceb5f44..93640193 100644 --- a/pytest_reportportal/service.py +++ b/pytest_reportportal/service.py @@ -198,6 +198,8 @@ def __init__(self, agent_config: AgentConfig) -> None: self._launch_id = None self.agent_name = "pytest-reportportal" self.agent_version = get_package_version(self.agent_name) or "None" + self._retry_tracker: dict[str, dict[str, Any]] = {} + self._active_leaves: dict[str, dict[str, Any]] = {} self.ignored_attributes = [] self.parent_item_id = None self.rp = None @@ -457,12 +459,31 @@ def _merge_leaf_types(self, test_tree: dict[str, Any], leaf_types: set, separato self._merge_leaf_types(child_leaf, leaf_types, separator) def _merge_dirs(self, test_tree: dict[str, Any]) -> None: + """Merge directory and file leaves using configured separator. + + :param test_tree: Test tree structure to merge + """ self._merge_leaf_types(test_tree, {LeafType.DIR, LeafType.FILE}, self._config.rp_dir_path_separator) - def _merge_code_with_separator(self, test_tree: dict[str, Any], separator: str) -> None: - self._merge_leaf_types(test_tree, {LeafType.CODE, LeafType.FILE, LeafType.DIR, LeafType.SUITE}, separator) + def _merge_code_with_separator(self, test_tree: dict[str, Any], separator: str, is_bdd: bool = False) -> None: + """Merge code and suite leaves, respecting hierarchy flags. + + :param test_tree: Test tree structure to merge + :param separator: Separator to use when merging names + :param is_bdd: If True, always merge FILE for BDD scenarios. Otherwise respect rp_hierarchy_test_file + """ + types_to_merge = {LeafType.CODE, LeafType.SUITE} + if is_bdd or not self._config.rp_hierarchy_test_file: + types_to_merge.add(LeafType.FILE) + if not self._config.rp_hierarchy_dirs: + types_to_merge.add(LeafType.DIR) + self._merge_leaf_types(test_tree, types_to_merge, separator) def _merge_code(self, test_tree: dict[str, Any]) -> None: + """Merge code and suite leaves using double colon separator. + + :param test_tree: Test tree structure to merge + """ self._merge_code_with_separator(test_tree, "::") def _build_item_paths(self, leaf: dict[str, Any], path: list[dict[str, Any]]) -> None: @@ -874,6 +895,8 @@ def _build_start_step_rq(self, leaf: dict[str, Any]) -> dict[str, Any]: "parameters": leaf.get("parameters", None), "parent_item_id": self._lock(leaf["parent"], lambda p: p["item_id"]), "test_case_id": leaf.get("test_case_id", None), + "retry": leaf.get("retry", False), + "retry_of": leaf.get("retry_of", None), } return payload @@ -906,7 +929,46 @@ def start_pytest_item(self, test_item: Optional[Item] = None): return self._create_suite_path(test_item) - current_leaf = self._tree_path[test_item][-1] + + item_key = self._get_item_key(test_item) + current_execution = self._detect_retry_attempt(test_item) + + if item_key in self._active_leaves: + current_leaf = self._active_leaves[item_key] + # If the leaf is already finished, this is a retry attempt + if current_leaf.get("exec") == ExecStatus.FINISHED: + # Finish the previous attempt (ensure it's marked as complete) + # Note: it should already be finished, but ensure consistency + prev_leaf = current_leaf + if prev_leaf.get("item_id") and prev_leaf.get("exec") == ExecStatus.FINISHED: + # Previous attempt is properly finished, proceed with retry + pass + + # Get tracker information for retry_of reference + tracker = self._retry_tracker.get(item_key, {"attempts": []}) + retry_of = tracker["attempts"][-1]["item_id"] if tracker["attempts"] else None + + # Create new retry leaf with proper structure + tree_path_leaf = self._tree_path[test_item][-1] + retry_leaf = { + **tree_path_leaf, + "item_id": None, + "exec": ExecStatus.CREATED, + "retry": True, + "retry_of": retry_of, + } + self._active_leaves[item_key] = retry_leaf + current_leaf = retry_leaf + elif current_leaf["item_id"] is not None: + # Leaf is in progress, don't start again (duplicate protection) + return + # else: leaf exists but not started yet, fall through to start it + + else: + # First time starting this item + current_leaf = self._tree_path[test_item][-1] + + # Start the item (first execution or retry attempt) self._process_metadata_item_start(current_leaf) item_id = self._start_step(self._build_start_step_rq(current_leaf)) current_leaf["item_id"] = item_id @@ -925,7 +987,12 @@ def process_results(self, test_item: Item, report): if PYTEST_BDD and _is_pytest_bdd_scenario(test_item.location[0]): return - leaf = self._tree_path[test_item][-1] + item_key = self._get_item_key(test_item) + if item_key in self._active_leaves: + leaf = self._active_leaves[item_key] + else: + leaf = self._tree_path[test_item][-1] + # Defining test result if report.when == "setup": leaf["status"] = "PASSED" @@ -945,12 +1012,18 @@ def _build_finish_step_rq(self, leaf: dict[str, Any]) -> dict[str, Any]: issue = NOT_ISSUE if status == "PASSED": issue = None + # Ensure attributes is always set (defaults to empty list if not present) + attributes = leaf.get("attributes") + if attributes is None: + attributes = [] payload = { - "attributes": leaf.get("attributes", None), + "attributes": attributes, "end_time": datetime.now(tz=timezone.utc), "status": status, "issue": issue, "item_id": leaf["item_id"], + "retry": leaf.get("retry", False), + "retry_of": leaf.get("retry_of", None), } return payload @@ -992,6 +1065,70 @@ def _finish_parents(self, leaf: dict[str, Any]) -> None: self._lock(leaf["parent"], lambda p: self._proceed_suite_finish(p)) self._finish_parents(leaf["parent"]) + def _get_item_key(self, test_item: Item) -> str: + """Get unique key for tracking an item across retries.""" + return str(id(test_item)) + + def _detect_retry_attempt(self, test_item: Item) -> int: + """Detect current retry attempt number from execution_count.""" + return getattr(test_item, 'execution_count', 1) + + def handle_retry_transition(self, test_item: Item, report) -> None: + """Detect and handle retry transitions when test is retried. + + Monitors execution_count to track retry attempts and update the tracker. + Note: Item creation/starting is now handled in start_pytest_item to ensure + proper lifecycle and avoid race conditions with setup phase. + """ + if report.when not in ("setup", "call"): + return + + current_execution = self._detect_retry_attempt(test_item) + item_key = self._get_item_key(test_item) + + if item_key not in self._retry_tracker: + self._retry_tracker[item_key] = { + "last_reported_execution_count": 0, + "attempts": [] + } + + tracker = self._retry_tracker[item_key] + + # Only update tracker for new execution counts + if current_execution > tracker["last_reported_execution_count"]: + if tracker["last_reported_execution_count"] == 0 and current_execution == 1: + # First execution: register in tracker + tree_path_leaf = self._tree_path[test_item][-1] + # Ensure the leaf is in active_leaves (start_pytest_item should have started it) + if item_key not in self._active_leaves: + self._active_leaves[item_key] = tree_path_leaf + + tracker["attempts"].append({ + "execution_count": 1, + "item_id": tree_path_leaf.get("item_id") + }) + tracker["last_reported_execution_count"] = 1 + elif current_execution > 1: + # Retry execution: item should already be created by start_pytest_item + # Just register it in the tracker + if item_key in self._active_leaves: + retry_leaf = self._active_leaves[item_key] + # Only record if the item has been started (has item_id) + if retry_leaf.get("item_id"): + # Avoid duplicate entries if called multiple times for same execution + last_attempt_execution = tracker["attempts"][-1]["execution_count"] if tracker["attempts"] else 0 + if last_attempt_execution < current_execution: + tracker["attempts"].append({ + "execution_count": current_execution, + "item_id": retry_leaf["item_id"] + }) + tracker["last_reported_execution_count"] = current_execution + + def cleanup_retry_state(self) -> None: + """Clean up retry tracking state after session ends.""" + self._retry_tracker.clear() + self._active_leaves.clear() + @check_rp_enabled def finish_pytest_item(self, test_item: Optional[Item] = None) -> None: """Finish pytest_item. @@ -1002,7 +1139,12 @@ def finish_pytest_item(self, test_item: Optional[Item] = None) -> None: if test_item is None: return - leaf = self._tree_path[test_item][-1] + item_key = self._get_item_key(test_item) + if item_key in self._active_leaves: + leaf = self._active_leaves[item_key] + else: + leaf = self._tree_path[test_item][-1] + self._process_metadata_item_finish(leaf) if PYTEST_BDD and _is_pytest_bdd_scenario(test_item.location[0]): @@ -1011,7 +1153,13 @@ def finish_pytest_item(self, test_item: Optional[Item] = None) -> None: self._finish_step(self._build_finish_step_rq(leaf)) leaf["exec"] = ExecStatus.FINISHED - self._finish_parents(leaf) + + current_execution = self._detect_retry_attempt(test_item) + tracker = self._retry_tracker.get(item_key, {}) + last_reported = tracker.get("last_reported_execution_count", 0) + + if current_execution == last_reported or current_execution == 1: + self._finish_parents(leaf) def _get_items(self, exec_status) -> list[Item]: return [k for k, v in self._tree_path.items() if v[-1]["exec"] == exec_status] @@ -1084,7 +1232,11 @@ def post_log( LOGGER.warning( "Incorrect loglevel = %s. Force set to INFO. " "Available levels: %s.", log_level, KNOWN_LOG_LEVELS ) - item_id = self._tree_path[test_item][-1]["item_id"] + item_key = self._get_item_key(test_item) + if item_key in self._active_leaves: + item_id = self._active_leaves[item_key]["item_id"] + else: + item_id = self._tree_path[test_item][-1]["item_id"] if PYTEST_BDD: if not item_id: # Check if we are actually a BDD scenario @@ -1185,7 +1337,7 @@ def start_bdd_scenario(self, feature: Feature, scenario: Scenario) -> None: self._generate_names(root_leaf) if not self._config.rp_hierarchy_code: try: - self._merge_code_with_separator(root_leaf, " - ") + self._merge_code_with_separator(root_leaf, " - ", is_bdd=True) except Exception as e: LOGGER.exception(e) self._build_item_paths(root_leaf, []) diff --git a/tests/integration/__init__.py b/tests/integration/__init__.py index d57be797..3d59848e 100644 --- a/tests/integration/__init__.py +++ b/tests/integration/__init__.py @@ -35,6 +35,7 @@ + [["examples/hierarchy/inner/test_inner_simple.py"]] * 7 + [["examples/hierarchy/test_in_class_in_class.py"]] + [["examples/test_simple.py"]] * 2 + + [["examples/hierarchy/inner/test_inner_simple.py"]] ) # noinspection PyTypeChecker @@ -65,6 +66,10 @@ dict(**utils.DEFAULT_VARIABLES), dict({"rp_hierarchy_test_file": False}, **utils.DEFAULT_VARIABLES), dict({"rp_hierarchy_test_file": False, "rp_hierarchy_dirs_level": 1}, **utils.DEFAULT_VARIABLES), + dict( + {"rp_hierarchy_dirs": True, "rp_hierarchy_test_file": True, "rp_hierarchy_code": False}, + **utils.DEFAULT_VARIABLES, + ), ] HIERARCHY_TEST_EXPECTED_ITEMS = [ @@ -271,6 +276,13 @@ ], [{"name": "examples::test_simple", "item_type": "STEP", "parent_item_id": lambda x: x is None}], [{"name": "test_simple", "item_type": "STEP", "parent_item_id": lambda x: x is None}], + [ + {"name": "examples", "item_type": "SUITE", "parent_item_id": lambda x: x is None}, + {"name": "hierarchy", "item_type": "SUITE", "parent_item_id": lambda x: x.startswith("examples")}, + {"name": "inner", "item_type": "SUITE", "parent_item_id": lambda x: x.startswith("hierarchy")}, + {"name": "test_inner_simple.py", "item_type": "SUITE", "parent_item_id": lambda x: x.startswith("inner")}, + {"name": "test_simple", "item_type": "STEP", "parent_item_id": lambda x: x.startswith("test_inner_simple.py")}, + ], ] HIERARCHY_TEST_PARAMETERS = [ diff --git a/tests/integration/test_attributes.py b/tests/integration/test_attributes.py index cff940c6..b3294439 100644 --- a/tests/integration/test_attributes.py +++ b/tests/integration/test_attributes.py @@ -121,7 +121,15 @@ def test_custom_runtime_attribute_report(mock_client_init): assert start_step_call_args["attributes"] == [{"key": "scope", "value": "smoke"}] finish_call_args = mock_client.finish_test_item.call_args_list - finish_step_call_args = finish_call_args[-1][1] + # Find the test item finish call (has attributes), not suite finish (no attributes) + finish_step_call_args = None + for call in finish_call_args: + if "attributes" in call[1]: + finish_step_call_args = call[1] + break + + assert finish_step_call_args is not None, "Could not find finish_test_item call with attributes" + actual_attributes = finish_step_call_args["attributes"] attribute_tuple_list = [(kv.get("key"), kv["value"]) for kv in actual_attributes] diff --git a/tests/integration/test_retry_rerunfailures.py b/tests/integration/test_retry_rerunfailures.py new file mode 100644 index 00000000..d0f80bea --- /dev/null +++ b/tests/integration/test_retry_rerunfailures.py @@ -0,0 +1,30 @@ +"""Integration tests for pytest-rerunfailures support.""" + +import pytest + + +@pytest.mark.flaky(reruns=2) +def test_eventual_pass_with_retries(): + """Test that passes after retries are properly reported.""" + if not hasattr(test_eventual_pass_with_retries, 'attempts'): + test_eventual_pass_with_retries.attempts = 0 + test_eventual_pass_with_retries.attempts += 1 + + # Passes on third attempt + assert test_eventual_pass_with_retries.attempts >= 3 + + +def test_without_retries(): + """Test that passes without retries.""" + assert True + + +@pytest.mark.flaky(reruns=1) +def test_passes_on_second_attempt(): + """Test that passes on second attempt.""" + if not hasattr(test_passes_on_second_attempt, 'count'): + test_passes_on_second_attempt.count = 0 + test_passes_on_second_attempt.count += 1 + + # Fails once, passes on second attempt + assert test_passes_on_second_attempt.count >= 2 diff --git a/tests/unit/test_retry_support.py b/tests/unit/test_retry_support.py new file mode 100644 index 00000000..f5c91ce6 --- /dev/null +++ b/tests/unit/test_retry_support.py @@ -0,0 +1,293 @@ +"""Tests for pytest-rerunfailures retry support.""" + +import pytest +from unittest import mock + +from pytest_reportportal.service import PyTestService + + +class TestRetryDetection: + """Tests for retry detection and state tracking.""" + + def test_detect_retry_returns_execution_count(self): + """Verify execution_count is returned.""" + from pytest_reportportal.config import AgentConfig + + config = mock.MagicMock(spec=AgentConfig) + service = PyTestService(config) + + test_item = mock.MagicMock() + test_item.execution_count = 1 + + result = service._detect_retry_attempt(test_item) + assert result == 1 + + def test_detect_retry_second_execution(self): + """Verify second execution is detected.""" + from pytest_reportportal.config import AgentConfig + + config = mock.MagicMock(spec=AgentConfig) + service = PyTestService(config) + + test_item = mock.MagicMock() + test_item.execution_count = 2 + + result = service._detect_retry_attempt(test_item) + assert result == 2 + + def test_detect_retry_defaults_when_missing(self): + """Verify defaults to 1 if attribute missing.""" + from pytest_reportportal.config import AgentConfig + + config = mock.MagicMock(spec=AgentConfig) + service = PyTestService(config) + + test_item = mock.MagicMock(spec=[]) + + result = service._detect_retry_attempt(test_item) + assert result == 1 + + def test_get_item_key_from_object_id(self): + """Verify item key is based on object id.""" + from pytest_reportportal.config import AgentConfig + + config = mock.MagicMock(spec=AgentConfig) + service = PyTestService(config) + + test_item = mock.MagicMock() + key = service._get_item_key(test_item) + + assert key == str(id(test_item)) + + def test_get_item_key_is_consistent(self): + """Verify same object returns same key.""" + from pytest_reportportal.config import AgentConfig + + config = mock.MagicMock(spec=AgentConfig) + service = PyTestService(config) + + test_item = mock.MagicMock() + key1 = service._get_item_key(test_item) + key2 = service._get_item_key(test_item) + + assert key1 == key2 + + def test_retry_tracker_tracks_attempts(self): + """Verify retry tracker records execution counts.""" + from pytest_reportportal.config import AgentConfig + + config = mock.MagicMock(spec=AgentConfig) + service = PyTestService(config) + + test_item = mock.MagicMock() + test_item.execution_count = 1 + + key = service._get_item_key(test_item) + service._retry_tracker[key] = {"last_reported_execution_count": 1} + + # Simulate second execution + test_item.execution_count = 2 + current = service._detect_retry_attempt(test_item) + last = service._retry_tracker[key]["last_reported_execution_count"] + + assert current > last + + +class TestRetryTransition: + """Tests for retry transition handling.""" + + def test_first_execution_registers_without_duplicate(self): + """Verify first execution registers tree leaf without creating duplicate.""" + from pytest_reportportal.config import AgentConfig + + config = mock.MagicMock(spec=AgentConfig) + service = PyTestService(config) + + test_item = mock.MagicMock() + test_item.execution_count = 1 + test_item.location = ("test_file.py",) + + key = service._get_item_key(test_item) + tree_leaf = { + "name": "test_item", + "item_id": "item-1", + "exec": "IN_PROGRESS" + } + service._tree_path[test_item] = [tree_leaf] + + report = mock.MagicMock() + report.when = "call" + + service.handle_retry_transition(test_item, report) + + tracker = service._retry_tracker[key] + assert tracker["last_reported_execution_count"] == 1 + assert len(tracker["attempts"]) == 1 + assert tracker["attempts"][0]["execution_count"] == 1 + + def test_ignores_non_call_and_setup_phases(self): + """Verify teardown phases are ignored.""" + from pytest_reportportal.config import AgentConfig + + config = mock.MagicMock(spec=AgentConfig) + service = PyTestService(config) + + test_item = mock.MagicMock() + test_item.execution_count = 1 + + key = service._get_item_key(test_item) + + report = mock.MagicMock() + report.when = "teardown" + + service.handle_retry_transition(test_item, report) + + assert key not in service._retry_tracker + + def test_setup_phase_is_processed(self): + """Verify setup phase is processed like call phase.""" + from pytest_reportportal.config import AgentConfig + + config = mock.MagicMock(spec=AgentConfig) + service = PyTestService(config) + + test_item = mock.MagicMock() + test_item.execution_count = 1 + + key = service._get_item_key(test_item) + tree_leaf = { + "name": "test_item", + "item_id": "item-1", + "exec": "IN_PROGRESS" + } + service._tree_path[test_item] = [tree_leaf] + + report = mock.MagicMock() + report.when = "setup" + + service.handle_retry_transition(test_item, report) + + tracker = service._retry_tracker[key] + assert tracker["last_reported_execution_count"] == 1 + + +class TestRetryMetadata: + """Tests for retry metadata in payloads.""" + + def test_finish_payload_includes_retry_fields(self): + """Verify finish payload has retry metadata.""" + from pytest_reportportal.config import AgentConfig + + config = mock.MagicMock(spec=AgentConfig) + service = PyTestService(config) + + leaf = { + "name": "test_retry", + "description": "Test", + "status": "PASSED", + "item_id": "item-123", + "retry": True, + "retry_of": "prev-item" + } + + payload = service._build_finish_step_rq(leaf) + + assert payload.get("retry") == True + assert payload.get("retry_of") == "prev-item" + + def test_finish_payload_defaults_retry_false(self): + """Verify retry defaults to false.""" + from pytest_reportportal.config import AgentConfig + + config = mock.MagicMock(spec=AgentConfig) + service = PyTestService(config) + + leaf = { + "name": "test_normal", + "description": "Test", + "status": "PASSED", + "item_id": "item-456" + } + + payload = service._build_finish_step_rq(leaf) + + assert payload.get("retry") == False + assert payload.get("retry_of") is None + + +class TestRetryStateManagement: + """Tests for retry state tracking and cleanup.""" + + def test_state_initialized(self): + """Verify state dicts are initialized.""" + from pytest_reportportal.config import AgentConfig + + config = mock.MagicMock(spec=AgentConfig) + service = PyTestService(config) + + assert hasattr(service, '_retry_tracker') + assert hasattr(service, '_active_leaves') + assert isinstance(service._retry_tracker, dict) + assert isinstance(service._active_leaves, dict) + + def test_cleanup_clears_all_state(self): + """Verify cleanup empties both dicts.""" + from pytest_reportportal.config import AgentConfig + + config = mock.MagicMock(spec=AgentConfig) + service = PyTestService(config) + + service._retry_tracker["key1"] = {"data": "value"} + service._active_leaves["key2"] = {"leaf": "data"} + + service.cleanup_retry_state() + + assert len(service._retry_tracker) == 0 + assert len(service._active_leaves) == 0 + + def test_active_leaves_updated_for_new_attempt(self): + """Verify active leaf is replaced for new attempt.""" + from pytest_reportportal.config import AgentConfig + + config = mock.MagicMock(spec=AgentConfig) + service = PyTestService(config) + + test_item = mock.MagicMock() + key = service._get_item_key(test_item) + + leaf1 = {"item_id": "attempt-1", "execution": 1} + service._active_leaves[key] = leaf1 + + assert service._active_leaves[key]["item_id"] == "attempt-1" + + leaf2 = {"item_id": "attempt-2", "execution": 2} + service._active_leaves[key] = leaf2 + + assert service._active_leaves[key]["item_id"] == "attempt-2" + + def test_post_log_uses_active_leaf(self): + """Verify post_log routes to active leaf when present.""" + from pytest_reportportal.config import AgentConfig + + config = mock.MagicMock(spec=AgentConfig) + service = PyTestService(config) + + test_item = mock.MagicMock() + key = service._get_item_key(test_item) + + # Setup mocks + service.rp = mock.MagicMock() + + active_leaf = {"item_id": "active-item-id"} + service._active_leaves[key] = active_leaf + + tree_leaf = {"item_id": "tree-item-id"} + service._tree_path[test_item] = [tree_leaf] + + service.post_log(test_item, "test message", "INFO") + + # Verify rp.log was called + assert service.rp.log.called + call_args = service.rp.log.call_args + # The item_id should come from active_leaf + assert call_args[1]["item_id"] == "active-item-id" diff --git a/tests/unit/test_service.py b/tests/unit/test_service.py index f28e18a7..bb4f6fdb 100644 --- a/tests/unit/test_service.py +++ b/tests/unit/test_service.py @@ -15,9 +15,16 @@ import os -from delayed_assert import assert_expectations, expect +try: + from delayed_assert import assert_expectations, expect +except ImportError: + # Fallback if delayed_assert is not available + def assert_expectations(): + pass + def expect(condition): + assert condition -from pytest_reportportal.service import _is_pytest_bdd_scenario +from pytest_reportportal.service import _is_pytest_bdd_scenario, LeafType def test_is_pytest_bdd_scenario_path(): @@ -85,3 +92,353 @@ def test_get_method_name_preserves_at_inside_params(mocked_item, rp_service): expect(result == "test_email[user@example.com]") assert_expectations() + + +def test_merge_code_with_separator_respects_hierarchy_flags(rp_service): + """Test that _merge_code_with_separator respects individual hierarchy flags (issue #409). + + This test verifies the core behavioral fix where hierarchy flags work independently: + - CODE and SUITE leaves are always merged together + - FILE leaves are merged only when rp_hierarchy_test_file=False + - DIR leaves are merged only when rp_hierarchy_dirs=False + """ + # Test case 1: Code disabled, dirs and test_file enabled (issue #409) + rp_service._config.rp_hierarchy_code = False + rp_service._config.rp_hierarchy_dirs = True + rp_service._config.rp_hierarchy_test_file = True + + # Simulate the method behavior by checking which leaf types are marked for merging + types_to_merge = {LeafType.CODE, LeafType.SUITE} + if not rp_service._config.rp_hierarchy_test_file: + types_to_merge.add(LeafType.FILE) + if not rp_service._config.rp_hierarchy_dirs: + types_to_merge.add(LeafType.DIR) + + # With rp_hierarchy_dirs=True and rp_hierarchy_test_file=True, + # FILE and DIR should NOT be in the merge set + assert LeafType.FILE not in types_to_merge, ( + "FILE should not be merged when rp_hierarchy_test_file=True (issue #409)" + ) + assert LeafType.DIR not in types_to_merge, ( + "DIR should not be merged when rp_hierarchy_dirs=True (issue #409)" + ) + + # Test case 2: Code disabled, dirs disabled, test_file enabled + rp_service._config.rp_hierarchy_dirs = False + + types_to_merge = {LeafType.CODE, LeafType.SUITE} + if not rp_service._config.rp_hierarchy_test_file: + types_to_merge.add(LeafType.FILE) + if not rp_service._config.rp_hierarchy_dirs: + types_to_merge.add(LeafType.DIR) + + # With rp_hierarchy_dirs=False, DIR should be in the merge set + assert LeafType.DIR in types_to_merge, ( + "DIR should be merged when rp_hierarchy_dirs=False" + ) + # But FILE should still not be merged + assert LeafType.FILE not in types_to_merge, ( + "FILE should not be merged when rp_hierarchy_test_file=True" + ) + + # Test case 3: BDD scenario forces FILE merging regardless of flag + rp_service._config.rp_hierarchy_code = False + rp_service._config.rp_hierarchy_dirs = True + rp_service._config.rp_hierarchy_test_file = True # Flag says keep FILE, but is_bdd=True overrides + + types_to_merge = {LeafType.CODE, LeafType.SUITE} + # Simulate is_bdd=True logic from _merge_code_with_separator + is_bdd = True + if is_bdd or not rp_service._config.rp_hierarchy_test_file: + types_to_merge.add(LeafType.FILE) + if not rp_service._config.rp_hierarchy_dirs: + types_to_merge.add(LeafType.DIR) + + # With is_bdd=True, FILE should be in the merge set even though rp_hierarchy_test_file=True + assert LeafType.FILE in types_to_merge, ( + "FILE should be merged for BDD scenarios (is_bdd=True) " + "to create correct Feature-Scenario names" + ) + + +def test_hierarchy_flags_issue_409_flag_combination(rp_service): + """Test the specific flag combination from issue #409. + + Issue #409 describes a bug where rp_hierarchy_code=False was overriding + rp_hierarchy_dirs and rp_hierarchy_test_file settings. + + This test verifies that with: + - rp_hierarchy_code=False + - rp_hierarchy_dirs=True + - rp_hierarchy_test_file=True + + The hierarchy flags now work independently as expected. + """ + # Set the exact configuration from issue #409 + rp_service._config.rp_hierarchy_code = False + rp_service._config.rp_hierarchy_dirs = True + rp_service._config.rp_hierarchy_test_file = True + + # Compute what should be merged based on the behavioral fix + types_to_merge = {LeafType.CODE, LeafType.SUITE} + + # These should NOT be added to the merge set because their hierarchy flags are True + if not rp_service._config.rp_hierarchy_test_file: + types_to_merge.add(LeafType.FILE) + if not rp_service._config.rp_hierarchy_dirs: + types_to_merge.add(LeafType.DIR) + + # Verify the fix: + # 1. CODE and SUITE are always merged + assert LeafType.CODE in types_to_merge, "CODE should always be merged" + assert LeafType.SUITE in types_to_merge, "SUITE should always be merged" + + # 2. FILE should NOT be merged because rp_hierarchy_test_file=True + assert LeafType.FILE not in types_to_merge, ( + "FILE should not be merged when rp_hierarchy_test_file=True. " + "This fixes issue #409 where code hierarchy was overriding file hierarchy." + ) + + # 3. DIR should NOT be merged because rp_hierarchy_dirs=True + assert LeafType.DIR not in types_to_merge, ( + "DIR should not be merged when rp_hierarchy_dirs=True. " + "This fixes issue #409 where code hierarchy was overriding dir hierarchy." + ) + + # The key point: even though rp_hierarchy_code=False (code hierarchy disabled), + # the directory and file hierarchies are preserved because their individual + # flags are True (enabled). + assert ( + len(types_to_merge) == 2 + ), "Only CODE and SUITE should be in merge set for this configuration" + + +def _build_test_tree(item=None, leaf_type=LeafType.ROOT, name="root", parent=None): + """Helper function to build a test tree for testing hierarchy merging. + + :param item: Optional pytest item or object reference + :param leaf_type: Type of the leaf (LeafType enum) + :param name: Name of the node + :param parent: Parent node (will be set automatically) + :return: Dictionary representing a leaf in the test tree + """ + leaf = { + "type": leaf_type, + "item": item or object(), + "name": name, + "children": {}, + "parent": parent + } + return leaf + + +def _add_child(parent_leaf, child_leaf, key=None): + """Helper function to add a child to a parent leaf. + + :param parent_leaf: Parent leaf dictionary + :param child_leaf: Child leaf dictionary + :param key: Key to use in children dict (defaults to child_leaf["item"]) + """ + key = key or child_leaf["item"] + child_leaf["parent"] = parent_leaf + parent_leaf["children"][key] = child_leaf + return child_leaf + + +def test_merge_code_with_separator_issue_409_functional(rp_service): + """Functional test for issue #409 hierarchy flag independence. + + This test verifies that the #409 flag combination works correctly by: + 1. Building a test tree with DIR, FILE, CODE, and SUITE nodes + 2. Setting the exact flag combination from issue #409 + 3. Calling _merge_code_with_separator with the tree + 4. Verifying that FILE and DIR are NOT merged (hierarchies are preserved) + + This prevents regression if the merging logic changes in the future. + """ + # Set the exact configuration from issue #409 + rp_service._config.rp_hierarchy_code = False + rp_service._config.rp_hierarchy_dirs = True + rp_service._config.rp_hierarchy_test_file = True + + # Build a realistic test tree structure: + # ROOT + # DIR (tests/) + # FILE (test_example.py) + # SUITE (TestClass) + # CODE (test_method1) + root = _build_test_tree(name="root", leaf_type=LeafType.ROOT) + + dir_item = object() + dir_leaf = _build_test_tree(item=dir_item, leaf_type=LeafType.DIR, name="tests") + _add_child(root, dir_leaf, dir_item) + + file_item = object() + file_leaf = _build_test_tree(item=file_item, leaf_type=LeafType.FILE, name="test_example.py") + _add_child(dir_leaf, file_leaf, file_item) + + suite_item = object() + suite_leaf = _build_test_tree(item=suite_item, leaf_type=LeafType.SUITE, name="TestClass") + _add_child(file_leaf, suite_leaf, suite_item) + + code_item = object() + code_leaf = _build_test_tree(item=code_item, leaf_type=LeafType.CODE, name="test_method1") + _add_child(suite_leaf, code_leaf, code_item) + + # Call _merge_code_with_separator which should respect the hierarchy flags + rp_service._merge_code_with_separator(root, "::") + + # Verify structure: DIR and FILE should still be separate due to hierarchy flags + # After merging, CODE should be merged with SUITE (code hierarchy disabled), + # but FILE and DIR should remain separate (their hierarchies enabled) + + # FILE should still have its own entry as a child of DIR + assert file_item in dir_leaf["children"], ( + "FILE should remain as separate child of DIR when rp_hierarchy_test_file=True" + ) + + # DIR should still have FILE as child + assert dir_item in root["children"], ( + "DIR should remain as separate child of ROOT when rp_hierarchy_dirs=True" + ) + + +def test_merge_code_with_separator_dirs_disabled(rp_service): + """Test that DIR hierarchy is merged when rp_hierarchy_dirs=False AND all its children are also mergeable. + + This verifies the conditional merging of DIR nodes works correctly. + Note: DIR is only removed if ALL its children are also in the merge set. + """ + # Set up configuration where dirs AND files should be merged + rp_service._config.rp_hierarchy_code = False + rp_service._config.rp_hierarchy_dirs = False # Merge DIR nodes + rp_service._config.rp_hierarchy_test_file = False # Also merge FILE nodes + + # Build test tree: ROOT -> DIR -> FILE -> CODE + root = _build_test_tree(name="root", leaf_type=LeafType.ROOT) + + dir_item = object() + dir_leaf = _build_test_tree(item=dir_item, leaf_type=LeafType.DIR, name="tests") + _add_child(root, dir_leaf, dir_item) + + file_item = object() + file_leaf = _build_test_tree(item=file_item, leaf_type=LeafType.FILE, name="test_example.py") + _add_child(dir_leaf, file_leaf, file_item) + + code_item = object() + code_leaf = _build_test_tree(item=code_item, leaf_type=LeafType.CODE, name="test_method") + _add_child(file_leaf, code_leaf, code_item) + + # Call merge method + rp_service._merge_code_with_separator(root, "::") + + # With both rp_hierarchy_dirs=False and rp_hierarchy_test_file=False, + # all nodes merge upward. CODE ends up as direct child of ROOT + # with a merged name including DIR, FILE, and CODE + assert code_item in root["children"], ( + "When rp_hierarchy_dirs=False and rp_hierarchy_test_file=False, CODE should become direct child of ROOT" + ) + + # DIR should no longer be in root's children (merged upward) + assert dir_item not in root["children"], ( + "When rp_hierarchy_dirs=False and all children are mergeable, DIR should be removed from ROOT" + ) + + # CODE name should include all merged prefixes: DIR, FILE, and CODE + merged_code = root["children"][code_item] + assert "tests" in merged_code["name"], ( + "CODE name should include merged DIR prefix" + ) + assert "test_example.py" in merged_code["name"], ( + "CODE name should include merged FILE prefix" + ) + + +def test_merge_code_with_separator_file_disabled(rp_service): + """Test that FILE hierarchy is merged when rp_hierarchy_test_file=False. + + This verifies FILE nodes are merged into CODE when test_file hierarchy is disabled. + """ + # Set up configuration where FILE should be merged + rp_service._config.rp_hierarchy_code = False + rp_service._config.rp_hierarchy_dirs = True + rp_service._config.rp_hierarchy_test_file = False # Merge FILE nodes + + # Build test tree: ROOT -> DIR -> FILE -> CODE + root = _build_test_tree(name="root", leaf_type=LeafType.ROOT) + + dir_item = object() + dir_leaf = _build_test_tree(item=dir_item, leaf_type=LeafType.DIR, name="tests") + _add_child(root, dir_leaf, dir_item) + + file_item = object() + file_leaf = _build_test_tree(item=file_item, leaf_type=LeafType.FILE, name="test_example.py") + _add_child(dir_leaf, file_leaf, file_item) + + code_item = object() + code_leaf = _build_test_tree(item=code_item, leaf_type=LeafType.CODE, name="test_method") + _add_child(file_leaf, code_leaf, code_item) + + # Call merge method + rp_service._merge_code_with_separator(root, "::") + + # With rp_hierarchy_test_file=False, CODE should be direct child of DIR + # FILE should be removed from tree + assert file_item not in dir_leaf["children"], ( + "When rp_hierarchy_test_file=False, FILE should be removed and its children moved up" + ) + + assert code_item in dir_leaf["children"], ( + "When rp_hierarchy_test_file=False, CODE should become direct child of DIR" + ) + + # CODE name should include FILE prefix due to merging + merged_code = dir_leaf["children"][code_item] + assert "test_example.py" in merged_code["name"], ( + "CODE name should include merged FILE prefix when rp_hierarchy_test_file=False" + ) + + +def test_merge_code_with_separator_bdd_forces_file_merge(rp_service): + """Test that BDD scenarios force FILE merging regardless of rp_hierarchy_test_file. + + This verifies the is_bdd parameter overrides the rp_hierarchy_test_file flag + to create proper Feature-Scenario names. + """ + # Set up configuration with file hierarchy enabled + rp_service._config.rp_hierarchy_code = False + rp_service._config.rp_hierarchy_dirs = True + rp_service._config.rp_hierarchy_test_file = True # Flag says keep FILE separate + + # Build test tree for BDD: ROOT -> SUITE (Feature) -> CODE (Scenario) + root = _build_test_tree(name="root", leaf_type=LeafType.ROOT) + + suite_item = object() + suite_leaf = _build_test_tree(item=suite_item, leaf_type=LeafType.SUITE, name="Feature: Login") + _add_child(root, suite_leaf, suite_item) + + code_item = object() + code_leaf = _build_test_tree(item=code_item, leaf_type=LeafType.CODE, name="Scenario: Valid login") + _add_child(suite_leaf, code_leaf, code_item) + + # Call merge method with is_bdd=True (should force FILE merge if FILE existed) + rp_service._merge_code_with_separator(root, "::", is_bdd=True) + + # With is_bdd=True and all children (CODE) in merge set, CODE should be moved to ROOT + assert code_item in root["children"], ( + "For BDD scenarios with is_bdd=True, CODE should become direct child of ROOT" + ) + + # SUITE should no longer be in ROOT since it was merged + assert suite_item not in root["children"], ( + "For BDD scenarios with is_bdd=True, SUITE should be removed from ROOT when all children are mergeable" + ) + + # Verify the merged name includes both Feature and Scenario + merged_code = root["children"][code_item] + assert "Feature: Login" in merged_code["name"], ( + "Merged BDD code should include Feature name" + ) + assert "Scenario: Valid login" in merged_code["name"], ( + "Merged BDD code should include Scenario name" + )