diff --git a/CHANGELOG.MD b/CHANGELOG.MD index 369c756..71ddcfa 100644 --- a/CHANGELOG.MD +++ b/CHANGELOG.MD @@ -6,6 +6,17 @@ This project adheres to [Semantic Versioning](https://semver.org/). Version numb - **MINOR**: New features that are backward-compatible. - **PATCH**: Bug fixes or minor changes that do not affect backward compatibility. +## [1.13.4] + +_released 03-18-2026 + +### Added + - Extended glob support for robot parser for efficient multiple test result file processing + - Added `--run-assigned-to-id` and `--clear-run-assigned-to-id` options to `add_run` command for clearing and setting test run assignees during run creation and updates + +### Fixed + - Cannot add empty runs via add_run command due to empty test case validation. + ## [1.13.3] _released 03-05-2026 diff --git a/README.md b/README.md index a2cb4f7..5f04c09 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ trcli ``` You should get something like this: ``` -TestRail CLI v1.13.3 +TestRail CLI v1.13.4 Copyright 2025 Gurock Software GmbH - www.gurock.com Supported and loaded modules: - parse_junit: JUnit XML Files (& Similar) @@ -51,7 +51,7 @@ CLI general reference -------- ```shell $ trcli --help -TestRail CLI v1.13.3 +TestRail CLI v1.13.4 Copyright 2025 Gurock Software GmbH - www.gurock.com Usage: trcli [OPTIONS] COMMAND [ARGS]... @@ -196,7 +196,7 @@ For further detail, please refer to the ### Using Glob Patterns for Multiple Files -TRCLI supports glob patterns to process multiple report files in a single command. This feature is available for **JUnit XML** and **Cucumber JSON** parsers. +TRCLI supports glob patterns to process multiple report files in a single command. This feature is available for **JUnit XML**, **Robot Framework**, and **Cucumber JSON** parsers. #### Important: Shell Quoting Requirement @@ -239,6 +239,7 @@ When a glob pattern matches **multiple files**, TRCLI automatically: 3. **Merges test results** into a single combined report 4. **Writes merged file** to current directory: - JUnit: `Merged-JUnit-report.xml` + - Robot Framework: `Merged-Robot-report.xml` - Cucumber: `merged_cucumber.json` 5. **Processes the merged file** as a single test run upload @@ -263,6 +264,23 @@ trcli parse_junit \ --case-matcher auto ``` +**Robot Framework - Multiple output files:** +```bash +# Merge multiple Robot Framework test runs +trcli -y \ + -h https://example.testrail.com \ + --project "My Project" \ + parse_robot \ + -f "reports/robot-*.xml" \ + --title "Merged Robot Tests" + +# Recursive search for all Robot outputs +trcli parse_robot \ + -f "test-results/**/output.xml" \ + --title "All Robot Results" \ + --case-matcher property +``` + **Cucumber JSON - Multiple test runs:** ```bash # Merge multiple Cucumber JSON reports @@ -1623,7 +1641,7 @@ Options: ### Reference ```shell $ trcli add_run --help -TestRail CLI v1.13.3 +TestRail CLI v1.13.4 Copyright 2025 Gurock Software GmbH - www.gurock.com Usage: trcli add_run [OPTIONS] @@ -1640,6 +1658,8 @@ Options: --run-end-date The expected or scheduled end date of this test run in MM/DD/YYYY format --run-assigned-to-id The ID of the user the test run should be assigned to. [x>=1] + --clear-run-assigned-to-id Clear the assignee of the test run (only valid + when updating with --run-id). --run-include-all Use this option to include all test cases in this test run. --auto-close-run Use this option to automatically close the created run. --run-case-ids Comma separated list of test case IDs to include in @@ -1712,6 +1732,39 @@ trcli -y -h https://example.testrail.io/ --project "My Project" \ - **Action Requirements**: `update` and `delete` actions require an existing run (--run-id must be provided) - **Validation**: Invalid reference formats are rejected with clear error messages +### Managing Assignees in Test Runs + +The `add_run` command supports comprehensive assignee management for test runs. You can assign runs to users when creating or updating them, and clear assignees when needed. + +#### Assigning Runs to Users + +When creating a new test run or updating an existing one, you can assign it to a user using the `--run-assigned-to-id` option: + +```bash +# Create a new run and assign to user ID 5 +trcli -y -h https://example.testrail.io/ --project "My Project" \ + add_run --title "My Test Run" --run-assigned-to-id 5 + +# Update an existing run and change the assignee +trcli -y -h https://example.testrail.io/ --project "My Project" \ + add_run --run-id 123 --title "My Test Run" --run-assigned-to-id 10 +``` + +#### Clearing Assignees from Test Runs + +To remove the assignee from an existing test run, use the `--clear-run-assigned-to-id` flag: + +```bash +# Clear the assignee from an existing run +trcli -y -h https://example.testrail.io/ --project "My Project" \ + add_run --run-id 123 --title "My Test Run" --clear-run-assigned-to-id +``` + +#### Assignee Management Rules + +- **Update Mode Only**: The `--clear-run-assigned-to-id` flag can only be used when updating an existing run (requires `--run-id`) +- **Mutually Exclusive**: You cannot use both `--run-assigned-to-id` and `--clear-run-assigned-to-id` in the same command + #### Examples **Complete Workflow Example:** @@ -1747,7 +1800,7 @@ providing you with a solid base of test cases, which you can further expand on T ### Reference ```shell $ trcli parse_openapi --help -TestRail CLI v1.13.3 +TestRail CLI v1.13.4 Copyright 2025 Gurock Software GmbH - www.gurock.com Usage: trcli parse_openapi [OPTIONS] diff --git a/tests/test_cmd_add_run.py b/tests/test_cmd_add_run.py index 88a52a7..eee18ef 100644 --- a/tests/test_cmd_add_run.py +++ b/tests/test_cmd_add_run.py @@ -42,9 +42,11 @@ def test_write_run_to_file_with_refs_and_description(self, mock_open_file): environment.run_assigned_to_id = assigned_to_id environment.run_case_ids = case_ids environment.run_include_all = True - expected_string = (f"run_assigned_to_id: {assigned_to_id}\nrun_case_ids: '{case_ids}'\n" - f"run_description: {description}\nrun_id: {run_id}\n" - f"run_include_all: true\nrun_refs: {refs}\ntitle: {title}\n") + expected_string = ( + f"run_assigned_to_id: {assigned_to_id}\nrun_case_ids: '{case_ids}'\n" + f"run_description: {description}\nrun_id: {run_id}\n" + f"run_include_all: true\nrun_refs: {refs}\ntitle: {title}\n" + ) cmd_add_run.write_run_to_file(environment, run_id) mock_open_file.assert_called_with(file, "a") mock_open_file.return_value.__enter__().write.assert_called_once_with(expected_string) @@ -52,35 +54,31 @@ def test_write_run_to_file_with_refs_and_description(self, mock_open_file): def test_cli_validation_refs_too_long(self): """Test that references validation fails when exceeding 250 characters""" from trcli.cli import Environment - + environment = Environment() environment.run_refs = "A" * 251 # 251 characters, exceeds limit - + assert len(environment.run_refs) > 250 - + runner = CliRunner() long_refs = "A" * 251 - - result = runner.invoke(cmd_add_run.cli, [ - '--title', 'Test Run', - '--run-refs', long_refs - ], catch_exceptions=False) - + + result = runner.invoke( + cmd_add_run.cli, ["--title", "Test Run", "--run-refs", long_refs], catch_exceptions=False + ) + # Should exit with error code 1 due to missing required parameters or validation assert result.exit_code == 1 def test_cli_validation_refs_exactly_250_chars(self): """Test that references validation passes with exactly 250 characters""" from trcli.cli import Environment - + runner = CliRunner() refs_250 = "A" * 250 # Exactly 250 characters, should pass validation - - result = runner.invoke(cmd_add_run.cli, [ - '--title', 'Test Run', - '--run-refs', refs_250 - ], catch_exceptions=False) - + + result = runner.invoke(cmd_add_run.cli, ["--title", "Test Run", "--run-refs", refs_250], catch_exceptions=False) + # Should not fail due to refs validation (will fail due to missing required parameters) # But the important thing is that it doesn't fail with the character limit error assert "References field cannot exceed 250 characters" not in result.output @@ -88,18 +86,18 @@ def test_cli_validation_refs_exactly_250_chars(self): def test_validation_logic_refs_action_without_run_id(self): """Test validation logic for refs action without run_id""" from trcli.cli import Environment - + # Update action validation environment = Environment() environment.run_refs_action = "update" environment.run_id = None environment.run_refs = "JIRA-123" - + # This should be invalid assert environment.run_refs_action == "update" assert environment.run_id is None - - # Delete action validation + + # Delete action validation environment.run_refs_action = "delete" assert environment.run_refs_action == "delete" assert environment.run_id is None @@ -107,37 +105,72 @@ def test_validation_logic_refs_action_without_run_id(self): def test_refs_action_parameter_parsing(self): """Test that refs action parameter is parsed correctly""" runner = CliRunner() - + # Test that the CLI accepts new param without crashing! :) - acuanico - result = runner.invoke(cmd_add_run.cli, ['--help']) + result = runner.invoke(cmd_add_run.cli, ["--help"]) assert result.exit_code == 0 assert "--run-refs-action" in result.output assert "Action to perform on references" in result.output + def test_clear_assigned_to_id_parameter_exists(self): + """Test that --clear-run-assigned-to-id parameter is available""" + runner = CliRunner() + + result = runner.invoke(cmd_add_run.cli, ["--help"]) + assert result.exit_code == 0 + assert "--clear-run-assigned-to-id" in result.output + assert "Clear the assignee" in result.output + + @mock.patch("trcli.cli.Environment.check_for_required_parameters") + def test_clear_assigned_to_id_requires_run_id(self, mock_check): + """Test that --clear-run-assigned-to-id requires --run-id""" + runner = CliRunner() + + result = runner.invoke(cmd_add_run.cli, ["--title", "Test Run", "--clear-run-assigned-to-id"]) + + assert result.exit_code == 1 + assert ( + "--clear-run-assigned-to-id can only be used when updating" in result.output + or "--run-id required" in result.output + ) + + @mock.patch("trcli.cli.Environment.check_for_required_parameters") + def test_clear_assigned_to_id_mutually_exclusive_with_assigned_to_id(self, mock_check): + """Test that --clear-run-assigned-to-id and --run-assigned-to-id are mutually exclusive""" + runner = CliRunner() + + result = runner.invoke( + cmd_add_run.cli, + ["--title", "Test Run", "--run-id", "123", "--run-assigned-to-id", "42", "--clear-run-assigned-to-id"], + ) + + assert result.exit_code == 1 + assert "cannot be used together" in result.output + class TestApiRequestHandlerReferences: """Test class for reference management functionality""" - + def test_manage_references_add(self): """Test adding references to existing ones""" from trcli.api.api_request_handler import ApiRequestHandler from trcli.cli import Environment from trcli.api.api_client import APIClient from trcli.data_classes.dataclass_testrail import TestRailSuite - + environment = Environment() api_client = APIClient("https://test.testrail.com") suite = TestRailSuite(name="Test Suite") handler = ApiRequestHandler(environment, api_client, suite) - + # Adding new references result = handler._manage_references("JIRA-100,JIRA-200", "JIRA-300,JIRA-400", "add") assert result == "JIRA-100,JIRA-200,JIRA-300,JIRA-400" - + # Adding duplicate references (should not duplicate) result = handler._manage_references("JIRA-100,JIRA-200", "JIRA-200,JIRA-300", "add") assert result == "JIRA-100,JIRA-200,JIRA-300" - + # Adding to empty existing references result = handler._manage_references("", "JIRA-100,JIRA-200", "add") assert result == "JIRA-100,JIRA-200" @@ -148,16 +181,16 @@ def test_manage_references_update(self): from trcli.cli import Environment from trcli.api.api_client import APIClient from trcli.data_classes.dataclass_testrail import TestRailSuite - + environment = Environment() api_client = APIClient("https://test.testrail.com") suite = TestRailSuite(name="Test Suite") handler = ApiRequestHandler(environment, api_client, suite) - + # Test replacing all references result = handler._manage_references("JIRA-100,JIRA-200", "JIRA-300,JIRA-400", "update") assert result == "JIRA-300,JIRA-400" - + # Test replacing with empty references result = handler._manage_references("JIRA-100,JIRA-200", "", "update") assert result == "" @@ -168,24 +201,24 @@ def test_manage_references_delete(self): from trcli.cli import Environment from trcli.api.api_client import APIClient from trcli.data_classes.dataclass_testrail import TestRailSuite - + environment = Environment() api_client = APIClient("https://test.testrail.com") suite = TestRailSuite(name="Test Suite") handler = ApiRequestHandler(environment, api_client, suite) - + # Deleting specific references result = handler._manage_references("JIRA-100,JIRA-200,JIRA-300", "JIRA-200", "delete") assert result == "JIRA-100,JIRA-300" - + # Deleting multiple specific references result = handler._manage_references("JIRA-100,JIRA-200,JIRA-300,JIRA-400", "JIRA-200,JIRA-400", "delete") assert result == "JIRA-100,JIRA-300" - + # Deleting all references (empty new_refs) result = handler._manage_references("JIRA-100,JIRA-200", "", "delete") assert result == "" - + # Deleting non-existent references result = handler._manage_references("JIRA-100,JIRA-200", "JIRA-999", "delete") assert result == "JIRA-100,JIRA-200" diff --git a/tests/test_glob_integration.py b/tests/test_glob_integration.py index 81aa056..68c9e30 100644 --- a/tests/test_glob_integration.py +++ b/tests/test_glob_integration.py @@ -146,6 +146,37 @@ def test_glob_junit_multiple_results_scenario_2(self): if merged_file.exists(): merged_file.unlink() + @pytest.mark.parse_robot + def test_glob_robot_duplicate_automation_ids(self): + """Test Robot Framework glob pattern with duplicate automation_ids.""" + env = Environment() + env.case_matcher = MatchersParser.AUTO + env.file = Path(__file__).parent / "test_data/XML/testglob_robot/*.xml" + + # Check if test files exist + if not list(Path(__file__).parent.glob("test_data/XML/testglob_robot/*.xml")): + pytest.skip("Robot test data not available") + + parser = RobotParser(env) + parsed_suites = parser.parse_file() + suite = parsed_suites[0] + + # Similar verification as JUnit tests + data_provider = ApiDataProvider(suite) + cases_to_add = data_provider.add_cases() + + # Verify deduplication occurred if there were duplicates + total_cases = sum(len(section.testcases) for section in suite.testsections) + automation_ids = [c.custom_automation_id for c in cases_to_add if c.custom_automation_id] + + # Cases to add should have unique automation_ids + assert len(automation_ids) == len(set(automation_ids)), "Cases to add should have unique automation_ids" + + # Clean up merged file + merged_file = Path.cwd() / "Merged-Robot-report.xml" + if merged_file.exists(): + merged_file.unlink() + @pytest.mark.parse_cucumber def test_cucumber_glob_filepath_not_pattern(self): """Test Scenario 3: Cucumber glob pattern uses correct filepath (not pattern string). diff --git a/tests/test_robot_parser.py b/tests/test_robot_parser.py index ca73dc8..02a7c27 100644 --- a/tests/test_robot_parser.py +++ b/tests/test_robot_parser.py @@ -76,3 +76,111 @@ def test_robot_xml_parser_file_not_found(self): env = Environment() env.file = Path(__file__).parent / "not_found.xml" RobotParser(env) + + @pytest.mark.parse_robot + def test_robot_xml_parser_glob_pattern_single_file(self): + """Test glob pattern that matches single file""" + env = Environment() + env.case_matcher = MatchersParser.AUTO + # Use glob pattern that matches only one file + env.file = Path(__file__).parent / "test_data/XML/robotframework_simple_RF50.xml" + + # This should work just like a regular file path + file_reader = RobotParser(env) + result = file_reader.parse_file() + + assert len(result) == 1 + assert isinstance(result[0], TestRailSuite) + # Verify it has test sections and cases + assert len(result[0].testsections) > 0 + + @pytest.mark.parse_robot + def test_robot_xml_parser_glob_pattern_multiple_files(self): + """Test glob pattern that matches multiple files and merges them""" + env = Environment() + env.case_matcher = MatchersParser.AUTO + # Use glob pattern that matches multiple Robot XML files + env.file = Path(__file__).parent / "test_data/XML/testglob_robot/*.xml" + + file_reader = RobotParser(env) + result = file_reader.parse_file() + + # Should return a merged result + assert len(result) == 1 + assert isinstance(result[0], TestRailSuite) + + # Verify merged file was created + merged_file = Path.cwd() / "Merged-Robot-report.xml" + assert merged_file.exists(), "Merged Robot report should be created" + + # Verify the merged result contains test cases from both files + total_cases = sum(len(section.testcases) for section in result[0].testsections) + assert total_cases > 0, "Merged result should contain test cases" + + # Clean up merged file + if merged_file.exists(): + merged_file.unlink() + + @pytest.mark.parse_robot + def test_robot_xml_parser_glob_pattern_no_matches(self): + """Test glob pattern that matches no files""" + with pytest.raises(FileNotFoundError): + env = Environment() + env.case_matcher = MatchersParser.AUTO + # Use glob pattern that matches no files + env.file = Path(__file__).parent / "test_data/XML/nonexistent_*.xml" + RobotParser(env) + + @pytest.mark.parse_robot + def test_robot_check_file_glob_returns_path(self): + """Test that check_file method returns valid Path for glob pattern""" + # Test single file match + single_file_glob = Path(__file__).parent / "test_data/XML/robotframework_simple_RF50.xml" + result = RobotParser.check_file(single_file_glob) + assert isinstance(result, Path) + assert result.exists() + + # Test multiple file match (returns merged file path) + multi_file_glob = Path(__file__).parent / "test_data/XML/testglob_robot/*.xml" + result = RobotParser.check_file(multi_file_glob) + assert isinstance(result, Path) + assert result.name == "Merged-Robot-report.xml" + assert result.exists() + + # Clean up + if result.exists() and result.name == "Merged-Robot-report.xml": + result.unlink() + + @pytest.mark.parse_robot + def test_robot_xml_parser_glob_merges_duplicate_sections(self): + """Test that glob pattern merging handles duplicate section names correctly. + + When multiple Robot XML files have the same suite structure, sections with + the same name should be merged into one section with all test cases combined. + This prevents the "Section duplicates detected" error. + """ + env = Environment() + env.case_matcher = MatchersParser.AUTO + env.file = Path(__file__).parent / "test_data/XML/testglob_robot/*.xml" + + file_reader = RobotParser(env) + result = file_reader.parse_file() + + assert len(result) == 1 + suite = result[0] + + # Verify no duplicate section names + section_names = [section.name for section in suite.testsections] + unique_section_names = set(section_names) + + assert len(section_names) == len(unique_section_names), f"Duplicate section names detected: {section_names}" + + # Verify sections have combined test cases from both files + # Both robot-1.xml and robot-2.xml have same structure, so sections should have tests from both + total_cases = sum(len(section.testcases) for section in suite.testsections) + assert total_cases > 4, "Sections should contain test cases from both merged files" + + # Clean up merged file + merged_file = Path.cwd() / "Merged-Robot-report.xml" + if merged_file.exists(): + merged_file.unlink() diff --git a/trcli/__init__.py b/trcli/__init__.py index f77d042..f7d8b10 100644 --- a/trcli/__init__.py +++ b/trcli/__init__.py @@ -1 +1 @@ -__version__ = "1.13.3" +__version__ = "1.13.4" diff --git a/trcli/api/api_request_handler.py b/trcli/api/api_request_handler.py index e1337b8..f0ee817 100644 --- a/trcli/api/api_request_handler.py +++ b/trcli/api/api_request_handler.py @@ -256,8 +256,11 @@ def update_run( milestone_id: int = None, refs: str = None, refs_action: str = "add", + assigned_to_id: Union[int, None] = ..., ) -> Tuple[dict, str]: - return self.run_handler.update_run(run_id, run_name, start_date, end_date, milestone_id, refs, refs_action) + return self.run_handler.update_run( + run_id, run_name, start_date, end_date, milestone_id, refs, refs_action, assigned_to_id + ) def _manage_references(self, existing_refs: str, new_refs: str, action: str) -> str: return self.run_handler._manage_references(existing_refs, new_refs, action) diff --git a/trcli/api/project_based_client.py b/trcli/api/project_based_client.py index fbfdfbc..55fbabe 100644 --- a/trcli/api/project_based_client.py +++ b/trcli/api/project_based_client.py @@ -213,6 +213,15 @@ def create_or_update_test_run(self) -> Tuple[int, str]: else: self.environment.log(f"Updating test run. ", new_line=False) run_id = self.environment.run_id + + # Determine assigned_to_id value based on flags + if hasattr(self.environment, "clear_run_assigned_to_id") and self.environment.clear_run_assigned_to_id: + assigned_to_id = None # Clear the assignee + elif self.environment.run_assigned_to_id: + assigned_to_id = self.environment.run_assigned_to_id # Set new assignee + else: + assigned_to_id = ... # Don't change (sentinel value) + run, error_message = self.api_request_handler.update_run( run_id, self.run_name, @@ -221,6 +230,7 @@ def create_or_update_test_run(self) -> Tuple[int, str]: milestone_id=self.environment.milestone_id, refs=self.environment.run_refs, refs_action=getattr(self.environment, "run_refs_action", "add"), + assigned_to_id=assigned_to_id, ) if self.environment.auto_close_run: self.environment.log("Closing run. ", new_line=False) diff --git a/trcli/api/run_handler.py b/trcli/api/run_handler.py index 94478ce..d30e972 100644 --- a/trcli/api/run_handler.py +++ b/trcli/api/run_handler.py @@ -8,7 +8,7 @@ - Closing and deleting runs """ -from beartype.typing import List, Tuple, Dict +from beartype.typing import List, Tuple, Dict, Union from trcli.api.api_client import APIClient from trcli.api.api_utils import ( @@ -89,8 +89,14 @@ def add_run( ) # Validate that we have test cases to include in the run - # Empty runs are not allowed unless include_all is True - if not include_all and (not add_run_data.get("case_ids") or len(add_run_data["case_ids"]) == 0): + # Empty runs are not allowed for parse commands unless include_all is True + # However, add_run command explicitly allows empty runs for later result uploads + is_add_run_command = self.environment.cmd == "add_run" + if ( + not is_add_run_command + and not include_all + and (not add_run_data.get("case_ids") or len(add_run_data["case_ids"]) == 0) + ): error_msg = ( "Cannot create test run: No test cases were matched.\n" " - For parse_junit: Ensure tests have automation_id/test ids that matches existing cases in TestRail\n" @@ -129,6 +135,7 @@ def update_run( milestone_id: int = None, refs: str = None, refs_action: str = "add", + assigned_to_id: Union[int, None] = ..., ) -> Tuple[dict, str]: """ Updates an existing run @@ -136,10 +143,11 @@ def update_run( :param run_id: run id :param run_name: run name :param start_date: start date - :param end_date: end date + :param end_date: end_date: end date :param milestone_id: milestone id :param refs: references to manage :param refs_action: action to perform ('add', 'update', 'delete') + :param assigned_to_id: user ID to assign (int), None to clear, or ... to leave unchanged :returns: Tuple with run and error string. """ run_response = self.client.send_get(f"get_run/{run_id}") @@ -161,6 +169,11 @@ def update_run( else: add_run_data["refs"] = existing_refs # Keep existing refs if none provided + # Handle assigned_to_id - only add to payload if explicitly provided + if assigned_to_id is not ...: + add_run_data["assignedto_id"] = assigned_to_id # Can be None (clears) or int (sets) + # else: Don't include assignedto_id in payload (no change to existing assignee) + existing_include_all = run_response.response_text.get("include_all", False) add_run_data["include_all"] = existing_include_all diff --git a/trcli/commands/cmd_add_run.py b/trcli/commands/cmd_add_run.py index 7e84073..cb6ac15 100644 --- a/trcli/commands/cmd_add_run.py +++ b/trcli/commands/cmd_add_run.py @@ -7,20 +7,22 @@ def print_config(env: Environment): - env.log(f"Parser Results Execution Parameters" - f"\n> TestRail instance: {env.host} (user: {env.username})" - f"\n> Project: {env.project if env.project else env.project_id}" - f"\n> Run title: {env.title}" - f"\n> Suite ID: {env.suite_id}" - f"\n> Description: {env.run_description}" - f"\n> Milestone ID: {env.milestone_id}" - f"\n> Start Date: {env.run_start_date}" - f"\n> End Date: {env.run_end_date}" - f"\n> Assigned To ID: {env.run_assigned_to_id}" - f"\n> Include All: {env.run_include_all}" - f"\n> Case IDs: {env.run_case_ids}" - f"\n> Refs: {env.run_refs}" - f"\n> Refs Action: {env.run_refs_action if hasattr(env, 'run_refs_action') else 'add'}") + env.log( + f"Parser Results Execution Parameters" + f"\n> TestRail instance: {env.host} (user: {env.username})" + f"\n> Project: {env.project if env.project else env.project_id}" + f"\n> Run title: {env.title}" + f"\n> Suite ID: {env.suite_id}" + f"\n> Description: {env.run_description}" + f"\n> Milestone ID: {env.milestone_id}" + f"\n> Start Date: {env.run_start_date}" + f"\n> End Date: {env.run_end_date}" + f"\n> Assigned To ID: {env.run_assigned_to_id}" + f"\n> Include All: {env.run_include_all}" + f"\n> Case IDs: {env.run_case_ids}" + f"\n> Refs: {env.run_refs}" + f"\n> Refs Action: {env.run_refs_action if hasattr(env, 'run_refs_action') else 'add'}" + ) def write_run_to_file(environment: Environment, run_id: int): @@ -28,15 +30,15 @@ def write_run_to_file(environment: Environment, run_id: int): environment.log(f"Writing test run data to file ({environment.file}). ", new_line=False) data = dict(title=environment.title, run_id=run_id) if environment.run_description: - data['run_description'] = environment.run_description + data["run_description"] = environment.run_description if environment.run_refs: - data['run_refs'] = environment.run_refs + data["run_refs"] = environment.run_refs if environment.run_include_all: - data['run_include_all'] = environment.run_include_all + data["run_include_all"] = environment.run_include_all if environment.run_case_ids: - data['run_case_ids'] = environment.run_case_ids + data["run_case_ids"] = environment.run_case_ids if environment.run_assigned_to_id: - data['run_assigned_to_id'] = environment.run_assigned_to_id + data["run_assigned_to_id"] = environment.run_assigned_to_id with open(environment.file, "a") as f: f.write(yaml.dump(data, default_flow_style=False)) environment.log("Done.") @@ -67,51 +69,47 @@ def write_run_to_file(environment: Environment, run_id: int): "--run-start-date", metavar="", default=None, - type=lambda x: [int(i) for i in x.split("/") if len(x.split("/")) == 3], - help="The expected or scheduled start date of this test run in MM/DD/YYYY format" + type=lambda x: [int(i) for i in x.split("/") if len(x.split("/")) == 3], + help="The expected or scheduled start date of this test run in MM/DD/YYYY format", ) @click.option( "--run-end-date", metavar="", default=None, - type=lambda x: [int(i) for i in x.split("/") if len(x.split("/")) == 3], - help="The expected or scheduled end date of this test run in MM/DD/YYYY format" + type=lambda x: [int(i) for i in x.split("/") if len(x.split("/")) == 3], + help="The expected or scheduled end date of this test run in MM/DD/YYYY format", ) @click.option( "--run-assigned-to-id", type=click.IntRange(min=1), metavar="", - help="The ID of the user the test run should be assigned to." + help="The ID of the user the test run should be assigned to.", ) @click.option( - "--run-include-all", + "--clear-run-assigned-to-id", is_flag=True, default=False, - help="Use this option to include all test cases in this test run." + help="Clear the assignee of the test run (only valid when updating with --run-id).", ) @click.option( - "--auto-close-run", - is_flag=True, - default=False, - help="Use this option to automatically close the created run." + "--run-include-all", is_flag=True, default=False, help="Use this option to include all test cases in this test run." ) @click.option( - "--run-case-ids", - metavar="", - type=lambda x: [int(i) for i in x.split(",")], - help="Comma separated list of test case IDs to include in the test run (i.e.: 1,2,3,4)." + "--auto-close-run", is_flag=True, default=False, help="Use this option to automatically close the created run." ) @click.option( - "--run-refs", + "--run-case-ids", metavar="", - help="A comma-separated list of references/requirements (up to 250 characters)" + type=lambda x: [int(i) for i in x.split(",")], + help="Comma separated list of test case IDs to include in the test run (i.e.: 1,2,3,4).", ) +@click.option("--run-refs", metavar="", help="A comma-separated list of references/requirements (up to 250 characters)") @click.option( "--run-refs-action", - type=click.Choice(['add', 'update', 'delete'], case_sensitive=False), - default='add', + type=click.Choice(["add", "update", "delete"], case_sensitive=False), + default="add", metavar="", - help="Action to perform on references: 'add' (default), 'update' (replace all), or 'delete' (remove all or specific)" + help="Action to perform on references: 'add' (default), 'update' (replace all), or 'delete' (remove all or specific)", ) @click.option("-f", "--file", type=click.Path(), metavar="", help="Write run data to file.") @click.pass_context @@ -121,18 +119,32 @@ def cli(environment: Environment, context: click.Context, *args, **kwargs): environment.cmd = "add_run" environment.set_parameters(context) environment.check_for_required_parameters() - + + # Validation: mutually exclusive assignee flags + if environment.run_assigned_to_id and environment.clear_run_assigned_to_id: + environment.elog("Error: --run-assigned-to-id and --clear-run-assigned-to-id cannot be used together.") + exit(1) + + # Validation: clear flag requires --run-id (update mode only) + if environment.clear_run_assigned_to_id and not environment.run_id: + environment.elog( + "Error: --clear-run-assigned-to-id can only be used when updating an existing run (--run-id required)." + ) + exit(1) + if environment.run_refs and len(environment.run_refs) > 250: environment.elog("Error: References field cannot exceed 250 characters.") exit(1) - - if environment.run_refs_action and environment.run_refs_action != 'add' and not environment.run_id: - environment.elog("Error: --run-refs-action 'update' and 'delete' can only be used when updating an existing run (--run-id required).") + + if environment.run_refs_action and environment.run_refs_action != "add" and not environment.run_id: + environment.elog( + "Error: --run-refs-action 'update' and 'delete' can only be used when updating an existing run (--run-id required)." + ) exit(1) - - if environment.run_refs_action == 'delete' and not environment.run_refs and environment.run_id: + + if environment.run_refs_action == "delete" and not environment.run_refs and environment.run_id: environment.run_refs = "" - + print_config(environment) project_client = ProjectBasedClient( diff --git a/trcli/readers/robot_xml.py b/trcli/readers/robot_xml.py index 0ef44e2..72e5088 100644 --- a/trcli/readers/robot_xml.py +++ b/trcli/readers/robot_xml.py @@ -1,6 +1,8 @@ from datetime import datetime, timedelta -from beartype.typing import List +from beartype.typing import List, Union +from pathlib import Path from xml.etree import ElementTree +import glob from trcli.backports import removeprefix from trcli.cli import Environment @@ -21,6 +23,54 @@ def __init__(self, environment: Environment): super().__init__(environment) self.case_matcher = environment.case_matcher + @staticmethod + def check_file(filepath: Union[str, Path]) -> Path: + """Check and process file path, supporting glob patterns. + + If the filepath contains glob patterns (*, ?, []), expand them: + - Single file match: Return that file path + - Multiple file matches: Merge the files and return merged file path + - No matches: Raise FileNotFoundError + """ + filepath = Path(filepath) + + # Check if this is a glob pattern (contains wildcards) + filepath_str = str(filepath) + if any(char in filepath_str for char in ["*", "?", "["]): + # Expand glob pattern + files = glob.glob(filepath_str, recursive=True) + + if not files: + raise FileNotFoundError(f"File not found: {filepath}") + elif len(files) == 1: + # Single file match - return it directly + return Path().cwd().joinpath(files[0]) + else: + # Multiple files - merge them + merged_root = ElementTree.Element("robot", generator="Robot 7.0 (merged)") + + for file_path in files: + tree = ElementTree.parse(file_path) + root = tree.getroot() + + # Merge all elements from each file + for suite in root.findall("suite"): + merged_root.append(suite) + + # Write merged XML to a file + merged_tree = ElementTree.ElementTree(merged_root) + merged_file_path = Path.cwd() / "Merged-Robot-report.xml" + + # Use UTF-8 encoding explicitly + merged_tree.write(merged_file_path, encoding="utf-8", xml_declaration=True) + + return merged_file_path + else: + # Not a glob pattern - use parent class behavior + if not filepath.is_file(): + raise FileNotFoundError(f"File not found: {filepath}") + return filepath + def parse_file(self) -> List[TestRailSuite]: self.env.log(f"Parsing Robot Framework report.") tree = ElementTree.parse(self.filepath) @@ -46,8 +96,14 @@ def _find_suites(self, suite_element, sections_list: List, namespace=""): namespace += f".{name}" if namespace else name tests = suite_element.findall("test") if tests: - section = TestRailSection(namespace) - sections_list.append(section) + # Check if section with this namespace already exists (for merged files with duplicate suites) + section = next((s for s in sections_list if s.name == namespace), None) + if section is None: + # Create new section if it doesn't exist + section = TestRailSection(namespace) + sections_list.append(section) + # else: reuse existing section and add tests to it + for test in tests: case_id = None case_name = test.get("name")