diff --git a/.github/workflows/pr-validation.yml b/.github/workflows/pr-validation.yml index e96a058..cabf027 100644 --- a/.github/workflows/pr-validation.yml +++ b/.github/workflows/pr-validation.yml @@ -89,6 +89,7 @@ jobs: - name: Check PR Description Completeness id: check_description + if: steps.check_release.outputs.is_release == 'false' run: | PR_BODY="${{ github.event.pull_request.body }}" @@ -124,23 +125,37 @@ jobs: const hasSolution = '${{ steps.check_description.outputs.has_solution }}' === 'true'; const hasTestSteps = '${{ steps.check_description.outputs.has_test_steps }}' === 'true'; + const skippedStatus = '➖ Skipped (Release Branch)'; + const issueStatus = isRelease - ? '➖ Skipped (Release Branch)' + ? skippedStatus : issueFound ? `✅ Found: ${issueKey} (${issueType})` : '⚠️ Missing'; + const issueSectionStatus = isRelease + ? skippedStatus + : hasIssue ? '✅ Present' : '⚠️ Missing'; + + const solutionStatus = isRelease + ? skippedStatus + : hasSolution ? '✅ Present' : '⚠️ Missing'; + + const testStepsStatus = isRelease + ? skippedStatus + : hasTestSteps ? '✅ Present' : '⚠️ Missing'; + const summary = `## 📋 PR Validation Summary | Check | Status | |-------|--------| | Issue Reference | ${issueStatus} | - | Issue Section | ${hasIssue ? '✅ Present' : '⚠️ Missing'} | - | Solution Description | ${hasSolution ? '✅ Present' : '⚠️ Missing'} | - | Test Steps | ${hasTestSteps ? '✅ Present' : '⚠️ Missing'} | + | Issue Section | ${issueSectionStatus} | + | Solution Description | ${solutionStatus} | + | Test Steps | ${testStepsStatus} | ${isRelease - ? '➖ Release branch - issue reference check skipped.' + ? '➖ Release branch - all validation checks skipped.' : (issueFound && hasSolution && hasTestSteps ? '✅ All checks passed!' : '⚠️ Some optional sections are missing.')} diff --git a/.gitignore b/.gitignore index 21afef3..b84b7fd 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ build .pytest_cache .coverage TEMP_* +.trcli/ diff --git a/CHANGELOG.MD b/CHANGELOG.MD index 821dd52..369c756 100644 --- a/CHANGELOG.MD +++ b/CHANGELOG.MD @@ -6,16 +6,27 @@ 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.3] + +_released 03-05-2026 + +### Added + - Added version notification system and a new `update` command to simplify upgrading to the latest version. + - Added comprehensive blob support to handle multiple test result files, improving automation workflows. + +### Improved + - Improved attachment handling and validation for custom fields. + ## [1.13.2] -_released 02-24-2025 +_released 02-24-2026 ### Fixed - Fixed an issue where automation_id matching fails due to wrapped values in HTML paragraph tags, causing case mismatch and duplicate test cases ## [1.13.1] -_released 02-19-2025 +_released 02-19-2026 ### Added - Added support for multiple case ids in a single test execution. @@ -25,7 +36,7 @@ _released 02-19-2025 ## [1.13.0] -_released 02-06-2025 +_released 02-06-2026 ### Added - **New Command: `parse_cucumber`** - Parse Cucumber JSON reports and upload to TestRail diff --git a/README.md b/README.md index 85c77d9..a2cb4f7 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ trcli ``` You should get something like this: ``` -TestRail CLI v1.13.2 +TestRail CLI v1.13.3 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.2 +TestRail CLI v1.13.3 Copyright 2025 Gurock Software GmbH - www.gurock.com Usage: trcli [OPTIONS] COMMAND [ARGS]... @@ -97,6 +97,7 @@ Commands: parse_openapi Parse OpenAPI spec and create cases in TestRail parse_robot Parse Robot Framework report and upload results to TestRail references Manage references in TestRail + update Update TRCLI to the latest version from PyPI. ``` Uploading automated test results @@ -190,9 +191,122 @@ Options: | `` | section | | `` | case | -For further detail, please refer to the +For further detail, please refer to the [JUnit to TestRail mapping](https://support.gurock.com/hc/en-us/articles/12989737200276) documentation. +### 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. + +#### Important: Shell Quoting Requirement + +**Glob patterns must be quoted** to prevent shell from expanding them prematurely. Without quotes, the shell will expand the pattern before passing it to TRCLI, causing unexpected errors. + +```bash +# CORRECT - Pattern quoted (TRCLI handles the expansion) +trcli parse_junit -f "reports/*.xml" --title "Test Results" +``` + +#### Supported Glob Patterns + +**Standard wildcards:** +```bash +# Match all XML files in directory +-f "reports/*.xml" + +# Match files with specific prefix +-f "target/surefire-reports/TEST-*.xml" + +# Match files with specific suffix +-f "build/test-results/*-report.xml" +``` + +**Recursive search** (matches subdirectories): +```bash +# Search all subdirectories recursively +-f "test-results/**/*.xml" + +# Match specific pattern in any subdirectory +-f "**/robot-output-*.xml" +``` + +#### How File Merging Works + +When a glob pattern matches **multiple files**, TRCLI automatically: + +1. **Expands the pattern** to find all matching files +2. **Parses each file** individually +3. **Merges test results** into a single combined report +4. **Writes merged file** to current directory: + - JUnit: `Merged-JUnit-report.xml` + - Cucumber: `merged_cucumber.json` +5. **Processes the merged file** as a single test run upload + +When a pattern matches **only one file**, TRCLI processes it directly without merging. + +#### Examples + +**JUnit XML - Multiple test suites:** +```bash +# Merge all JUnit XML files from Maven surefire reports +trcli -y \ + -h https://example.testrail.com \ + --project "My Project" \ + parse_junit \ + -f "target/surefire-reports/junitreports/*.xml" \ + --title "Merged Test Results" + +# Merge test results from multiple modules +trcli parse_junit \ + -f "build/test-results/**/*.xml" \ + --title "All Module Tests" \ + --case-matcher auto +``` + +**Cucumber JSON - Multiple test runs:** +```bash +# Merge multiple Cucumber JSON reports +trcli -y \ + -h https://example.testrail.com \ + --project "My Project" \ + parse_cucumber \ + -f "reports/cucumber-*.json" \ + --title "Merged Cucumber Tests" + +# Recursive search for all Cucumber JSON results +trcli parse_cucumber \ + -f "test-results/**/cucumber.json" \ + --title "All Cucumber Results" \ + --case-matcher auto +``` + +#### Troubleshooting + +**Error: "Got unexpected extra argument"** +- **Cause:** Glob pattern not quoted - shell expanded it before TRCLI +- **Solution:** Add quotes around the pattern: `-f "reports/*.xml"` + +**Error: "File not found"** +- **Cause:** No files match the glob pattern +- **Solution:** Verify the pattern and file paths: + ```bash + # Check what files match your pattern + ls reports/*.xml + + # Use absolute path if relative path doesn't work + trcli parse_junit -f "/full/path/to/reports/*.xml" + ``` + +**Pattern matches nothing in subdirectories:** +- **Cause:** Need recursive glob (`**`) +- **Solution:** Use `**` for recursive matching: `-f "reports/**/*.xml"` + +#### Limitations + +1. Glob patterns are expanded by Python's `glob` module (not shell), so some advanced bash features may not work +2. Very large numbers of files (100+) may cause performance issues during merging +3. Merged files are created in the current working directory + ### Uploading test results To submit test case results, the TestRail CLI will attempt to match the test cases in your automation suite to test cases in TestRail. There are 2 mechanisms to match test cases: @@ -1509,7 +1623,7 @@ Options: ### Reference ```shell $ trcli add_run --help -TestRail CLI v1.13.2 +TestRail CLI v1.13.3 Copyright 2025 Gurock Software GmbH - www.gurock.com Usage: trcli add_run [OPTIONS] @@ -1633,7 +1747,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.2 +TestRail CLI v1.13.3 Copyright 2025 Gurock Software GmbH - www.gurock.com Usage: trcli parse_openapi [OPTIONS] diff --git a/setup.py b/setup.py index f1946bb..487ce7f 100644 --- a/setup.py +++ b/setup.py @@ -21,10 +21,14 @@ "junitparser>=3.1.0,<4.0.0", "pyserde==0.12.*", "requests>=2.31.0,<3.0.0", + "urllib3>=1.26.0,<3.0.0", + "charset-normalizer>=2.0.0,<4.0.0", + "chardet>=5.0.0,<6.0.0", "tqdm>=4.65.0,<5.0.0", "humanfriendly>=10.0.0,<11.0.0", "openapi-spec-validator>=0.5.0,<1.0.0", "beartype>=0.17.0,<1.0.0", + "packaging>=20.0", "prance", # Does not use semantic versioning ], entry_points=""" diff --git a/tests/test_cmd_update.py b/tests/test_cmd_update.py new file mode 100644 index 0000000..8e14874 --- /dev/null +++ b/tests/test_cmd_update.py @@ -0,0 +1,202 @@ +""" +Unit tests for cmd_update command. +""" + +import pytest +from unittest.mock import patch, MagicMock +from click.testing import CliRunner + +from trcli.commands.cmd_update import cli as update_cli +from trcli import __version__ + + +@pytest.fixture +def runner(): + """Create a Click test runner.""" + return CliRunner() + + +class TestUpdateCommand: + """Tests for trcli update command.""" + + def test_update_check_only_with_newer_version(self, runner): + """Test --check-only flag when newer version is available.""" + mock_msg = "\nA new version of TestRail CLI is available!\n..." + with patch("trcli.commands.cmd_update._query_pypi", return_value="1.14.0"), patch( + "trcli.commands.cmd_update.__version__", "1.13.1" + ), patch("trcli.commands.cmd_update._compare_and_format", return_value=mock_msg): + result = runner.invoke(update_cli, ["--check-only"]) + + assert result.exit_code == 0 + assert "Current version: 1.13.1" in result.output + assert "Latest version on PyPI: 1.14.0" in result.output + assert "A new version of TestRail CLI is available" in result.output + + def test_update_check_only_already_latest(self, runner): + """Test --check-only flag when already on latest version.""" + with patch("trcli.commands.cmd_update._query_pypi", return_value="1.13.1"), patch( + "trcli.commands.cmd_update.__version__", "1.13.1" + ), patch("trcli.commands.cmd_update._compare_and_format", return_value=None): + result = runner.invoke(update_cli, ["--check-only"]) + + assert result.exit_code == 0 + assert "You are already on the latest version" in result.output + + def test_update_check_only_pypi_failure(self, runner): + """Test --check-only flag when PyPI query fails.""" + with patch("trcli.commands.cmd_update._query_pypi", return_value=None): + result = runner.invoke(update_cli, ["--check-only"]) + + assert result.exit_code == 1 + assert "Failed to query PyPI" in result.output + + def test_update_already_latest_no_force(self, runner): + """Test update when already on latest version without --force.""" + with patch("trcli.commands.cmd_update._query_pypi", return_value="1.13.1"), patch( + "trcli.commands.cmd_update.__version__", "1.13.1" + ), patch("trcli.commands.cmd_update._compare_and_format", return_value=None): + result = runner.invoke(update_cli) + + assert result.exit_code == 0 + assert "You are already on the latest version" in result.output + assert "Use --force to reinstall" in result.output + + def test_update_cancelled_by_user(self, runner): + """Test update cancelled by user at confirmation prompt.""" + mock_msg = "\nA new version available\n" + with patch("trcli.commands.cmd_update._query_pypi", return_value="1.14.0"), patch( + "trcli.commands.cmd_update.__version__", "1.13.1" + ), patch("trcli.commands.cmd_update._compare_and_format", return_value=mock_msg): + # Simulate user saying 'no' at confirmation + result = runner.invoke(update_cli, input="n\n") + + assert result.exit_code == 0 + assert "Update cancelled" in result.output + + def test_update_successful(self, runner): + """Test successful update.""" + mock_subprocess_result = MagicMock() + mock_subprocess_result.returncode = 0 + mock_msg = "\nA new version available\n" + + with patch("trcli.commands.cmd_update._query_pypi", return_value="1.14.0"), patch( + "trcli.commands.cmd_update.__version__", "1.13.1" + ), patch("trcli.commands.cmd_update._compare_and_format", return_value=mock_msg), patch( + "subprocess.run", return_value=mock_subprocess_result + ): + # Simulate user saying 'yes' at confirmation + result = runner.invoke(update_cli, input="y\n") + + assert result.exit_code == 0 + assert "Update completed successfully" in result.output + + def test_update_failed_subprocess(self, runner): + """Test update when pip subprocess fails.""" + mock_subprocess_result = MagicMock() + mock_subprocess_result.returncode = 1 + mock_msg = "\nA new version available\n" + + with patch("trcli.commands.cmd_update._query_pypi", return_value="1.14.0"), patch( + "trcli.commands.cmd_update.__version__", "1.13.1" + ), patch("trcli.commands.cmd_update._compare_and_format", return_value=mock_msg), patch( + "subprocess.run", return_value=mock_subprocess_result + ): + # Simulate user saying 'yes' at confirmation + result = runner.invoke(update_cli, input="y\n") + + assert result.exit_code == 1 + assert "Update failed" in result.output + assert "Common issues and solutions" in result.output + + def test_update_force_reinstall(self, runner): + """Test update with --force flag.""" + mock_subprocess_result = MagicMock() + mock_subprocess_result.returncode = 0 + + with patch("trcli.commands.cmd_update._query_pypi", return_value="1.13.1"), patch( + "trcli.commands.cmd_update.__version__", "1.13.1" + ), patch("subprocess.run", return_value=mock_subprocess_result) as mock_run: + # Simulate user saying 'yes' at confirmation + result = runner.invoke(update_cli, ["--force"], input="y\n") + + assert result.exit_code == 0 + assert "Forcing reinstall" in result.output + assert "Update completed successfully" in result.output + + # Verify --force-reinstall was passed to pip + call_args = mock_run.call_args[0][0] + assert "--force-reinstall" in call_args + + def test_update_pip_not_found(self, runner): + """Test update when pip is not available.""" + mock_msg = "\nA new version available\n" + with patch("trcli.commands.cmd_update._query_pypi", return_value="1.14.0"), patch( + "trcli.commands.cmd_update.__version__", "1.13.1" + ), patch("trcli.commands.cmd_update._compare_and_format", return_value=mock_msg), patch( + "subprocess.run", side_effect=FileNotFoundError("pip not found") + ): + # Simulate user saying 'yes' at confirmation + result = runner.invoke(update_cli, input="y\n") + + assert result.exit_code == 1 + assert "pip not found" in result.output + + def test_update_keyboard_interrupt(self, runner): + """Test update interrupted by user (Ctrl+C).""" + mock_msg = "\nA new version available\n" + with patch("trcli.commands.cmd_update._query_pypi", return_value="1.14.0"), patch( + "trcli.commands.cmd_update.__version__", "1.13.1" + ), patch("trcli.commands.cmd_update._compare_and_format", return_value=mock_msg), patch( + "subprocess.run", side_effect=KeyboardInterrupt() + ): + # Simulate user saying 'yes' at confirmation + result = runner.invoke(update_cli, input="y\n") + + assert result.exit_code == 130 + assert "interrupted" in result.output + + def test_update_unexpected_exception(self, runner): + """Test update with unexpected exception.""" + mock_msg = "\nA new version available\n" + with patch("trcli.commands.cmd_update._query_pypi", return_value="1.14.0"), patch( + "trcli.commands.cmd_update.__version__", "1.13.1" + ), patch("trcli.commands.cmd_update._compare_and_format", return_value=mock_msg), patch( + "subprocess.run", side_effect=Exception("Unexpected error") + ): + # Simulate user saying 'yes' at confirmation + result = runner.invoke(update_cli, input="y\n") + + assert result.exit_code == 1 + assert "Unexpected error" in result.output + + def test_update_shows_current_and_latest_version(self, runner): + """Test that update command shows current and latest versions.""" + mock_msg = "\nA new version available\n" + with patch("trcli.commands.cmd_update._query_pypi", return_value="1.14.0"), patch( + "trcli.commands.cmd_update.__version__", "1.13.1" + ), patch("trcli.commands.cmd_update._compare_and_format", return_value=mock_msg): + result = runner.invoke(update_cli, input="n\n") + + assert "Current version: 1.13.1" in result.output + assert "Latest version on PyPI: 1.14.0" in result.output + + def test_update_command_uses_sys_executable(self, runner): + """Test that update uses sys.executable to call pip.""" + import sys + + mock_subprocess_result = MagicMock() + mock_subprocess_result.returncode = 0 + mock_msg = "\nA new version available\n" + + with patch("trcli.commands.cmd_update._query_pypi", return_value="1.14.0"), patch( + "trcli.commands.cmd_update.__version__", "1.13.1" + ), patch("trcli.commands.cmd_update._compare_and_format", return_value=mock_msg), patch( + "subprocess.run", return_value=mock_subprocess_result + ) as mock_run: + # Simulate user saying 'yes' at confirmation + result = runner.invoke(update_cli, input="y\n") + + # Verify sys.executable was used + call_args = mock_run.call_args[0][0] + assert call_args[0] == sys.executable + assert call_args[1:4] == ["-m", "pip", "install"] diff --git a/tests/test_cucumber_parser.py b/tests/test_cucumber_parser.py index 03b3388..859d6e4 100644 --- a/tests/test_cucumber_parser.py +++ b/tests/test_cucumber_parser.py @@ -254,3 +254,92 @@ def test_cucumber_indentation_in_generated_feature(self, advanced_environment): # Examples should be indented with 4 spaces examples_lines = [l for l in lines if "Examples:" in l] assert any(l.startswith(" Examples:") for l in examples_lines) + + @pytest.mark.parse_cucumber + def test_cucumber_json_parser_glob_pattern_single_file(self): + """Test glob pattern that matches single file""" + env = Environment() + env.case_matcher = MatchersParser.AUTO + env.suite_name = None + # Use single file path + env.file = Path(__file__).parent / "test_data/CUCUMBER/sample_cucumber.json" + + # This should work just like a regular file path + parser = CucumberParser(env) + result = parser.parse_file() + + assert len(result) == 1 + from trcli.data_classes.dataclass_testrail import TestRailSuite + + assert isinstance(result[0], TestRailSuite) + # Verify it has test sections and cases + assert len(result[0].testsections) > 0 + + @pytest.mark.parse_cucumber + def test_cucumber_json_parser_glob_pattern_multiple_files(self): + """Test glob pattern that matches multiple files and merges them""" + env = Environment() + env.case_matcher = MatchersParser.AUTO + env.suite_name = None + # Use glob pattern that matches multiple Cucumber JSON files + env.file = Path(__file__).parent / "test_data/CUCUMBER/testglob/*.json" + + parser = CucumberParser(env) + result = parser.parse_file() + + # Should return a merged result + assert len(result) == 1 + from trcli.data_classes.dataclass_testrail import TestRailSuite + + assert isinstance(result[0], TestRailSuite) + + # Verify merged file was created + merged_file = Path.cwd() / "Merged-Cucumber-report.json" + assert merged_file.exists(), "Merged Cucumber 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_cucumber + def test_cucumber_json_parser_glob_pattern_no_matches(self): + """Test glob pattern that matches no files""" + with pytest.raises(FileNotFoundError): + env = Environment() + env.case_matcher = MatchersParser.AUTO + env.suite_name = None + # Use glob pattern that matches no files + env.file = Path(__file__).parent / "test_data/CUCUMBER/nonexistent_*.json" + CucumberParser(env) + + @pytest.mark.parse_cucumber + def test_cucumber_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/CUCUMBER/sample_cucumber.json" + result = CucumberParser.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/CUCUMBER/testglob/*.json" + result = CucumberParser.check_file(multi_file_glob) + assert isinstance(result, Path) + assert result.name == "Merged-Cucumber-report.json" + assert result.exists() + + # Verify merged file contains valid JSON array + import json + + with open(result, "r", encoding="utf-8") as f: + merged_data = json.load(f) + assert isinstance(merged_data, list), "Merged Cucumber JSON should be an array" + assert len(merged_data) > 0, "Merged array should contain features" + + # Clean up + if result.exists() and result.name == "Merged-Cucumber-report.json": + result.unlink() diff --git a/tests/test_data/CUCUMBER/testglob/cucumber1.json b/tests/test_data/CUCUMBER/testglob/cucumber1.json new file mode 100644 index 0000000..b1863d2 --- /dev/null +++ b/tests/test_data/CUCUMBER/testglob/cucumber1.json @@ -0,0 +1,175 @@ +[ + { + "uri": "features/login.feature", + "id": "user-login", + "keyword": "Feature", + "name": "User Login", + "description": " As a user\n I want to log into the application\n So that I can access my account", + "line": 1, + "tags": [ + { + "name": "@smoke", + "line": 1 + }, + { + "name": "@authentication", + "line": 1 + } + ], + "elements": [ + { + "id": "user-login;successful-login-with-valid-credentials", + "keyword": "Scenario", + "name": "Successful login with valid credentials", + "description": "", + "line": 7, + "type": "scenario", + "tags": [ + { + "name": "@positive", + "line": 6 + } + ], + "steps": [ + { + "keyword": "Given ", + "name": "I am on the login page", + "line": 8, + "match": { + "location": "step_definitions/login_steps.js:10" + }, + "result": { + "status": "passed", + "duration": 1234567890 + } + }, + { + "keyword": "When ", + "name": "I enter valid username \"testuser\"", + "line": 9, + "match": { + "location": "step_definitions/login_steps.js:15" + }, + "result": { + "status": "passed", + "duration": 987654321 + } + }, + { + "keyword": "And ", + "name": "I enter valid password \"password123\"", + "line": 10, + "match": { + "location": "step_definitions/login_steps.js:20" + }, + "result": { + "status": "passed", + "duration": 876543210 + } + }, + { + "keyword": "And ", + "name": "I click the login button", + "line": 11, + "match": { + "location": "step_definitions/login_steps.js:25" + }, + "result": { + "status": "passed", + "duration": 2345678901 + } + }, + { + "keyword": "Then ", + "name": "I should be redirected to the dashboard", + "line": 12, + "match": { + "location": "step_definitions/login_steps.js:30" + }, + "result": { + "status": "passed", + "duration": 543210987 + } + } + ] + }, + { + "id": "user-login;failed-login-with-invalid-credentials", + "keyword": "Scenario", + "name": "Failed login with invalid credentials", + "description": "", + "line": 15, + "type": "scenario", + "tags": [ + { + "name": "@negative", + "line": 14 + } + ], + "steps": [ + { + "keyword": "Given ", + "name": "I am on the login page", + "line": 16, + "match": { + "location": "step_definitions/login_steps.js:10" + }, + "result": { + "status": "passed", + "duration": 1234567890 + } + }, + { + "keyword": "When ", + "name": "I enter invalid username \"baduser\"", + "line": 17, + "match": { + "location": "step_definitions/login_steps.js:15" + }, + "result": { + "status": "passed", + "duration": 987654321 + } + }, + { + "keyword": "And ", + "name": "I enter invalid password \"wrongpass\"", + "line": 18, + "match": { + "location": "step_definitions/login_steps.js:20" + }, + "result": { + "status": "passed", + "duration": 876543210 + } + }, + { + "keyword": "And ", + "name": "I click the login button", + "line": 19, + "match": { + "location": "step_definitions/login_steps.js:25" + }, + "result": { + "status": "passed", + "duration": 2345678901 + } + }, + { + "keyword": "Then ", + "name": "I should see an error message \"Invalid credentials\"", + "line": 20, + "match": { + "location": "step_definitions/login_steps.js:35" + }, + "result": { + "status": "failed", + "duration": 543210987, + "error_message": "AssertionError: expected 'Please try again' to equal 'Invalid credentials'" + } + } + ] + } + ] + } +] diff --git a/tests/test_data/CUCUMBER/testglob/cucumber2.json b/tests/test_data/CUCUMBER/testglob/cucumber2.json new file mode 100644 index 0000000..19ac15a --- /dev/null +++ b/tests/test_data/CUCUMBER/testglob/cucumber2.json @@ -0,0 +1,234 @@ +[ + { + "uri": "features/shopping_cart.feature", + "id": "shopping-cart", + "keyword": "Feature", + "name": "Shopping Cart", + "description": " As a customer\n I want to manage my shopping cart\n So that I can purchase items", + "line": 1, + "tags": [ + { + "name": "@shopping", + "line": 1 + }, + { + "name": "@cart", + "line": 1 + } + ], + "elements": [ + { + "id": "shopping-cart;background", + "keyword": "Background", + "name": "User is logged in", + "description": "", + "line": 5, + "type": "background", + "steps": [ + { + "keyword": "Given ", + "name": "I am logged in as a customer", + "line": 6, + "match": { + "location": "step_definitions/auth_steps.js:10" + }, + "result": { + "status": "passed", + "duration": 1000000000 + } + }, + { + "keyword": "And ", + "name": "my shopping cart is empty", + "line": 7, + "match": { + "location": "step_definitions/cart_steps.js:5" + }, + "result": { + "status": "passed", + "duration": 500000000 + } + } + ] + }, + { + "id": "shopping-cart;add-items-to-cart", + "keyword": "Scenario Outline", + "name": "Add items to cart", + "description": "", + "line": 10, + "type": "scenario_outline", + "tags": [ + { + "name": "@positive", + "line": 9 + } + ], + "steps": [ + { + "keyword": "When ", + "name": "I add \"\" of \"\" to my cart", + "line": 11, + "match": { + "location": "step_definitions/cart_steps.js:10" + }, + "result": { + "status": "passed", + "duration": 2000000000 + } + }, + { + "keyword": "Then ", + "name": "my cart should contain \"\" items", + "line": 12, + "match": { + "location": "step_definitions/cart_steps.js:20" + }, + "result": { + "status": "passed", + "duration": 1000000000 + } + }, + { + "keyword": "And ", + "name": "the total price should be \"\"", + "line": 13, + "match": { + "location": "step_definitions/cart_steps.js:30" + }, + "result": { + "status": "passed", + "duration": 500000000 + } + } + ], + "examples": [ + { + "keyword": "Examples", + "name": "Valid products", + "description": "", + "line": 15, + "tags": [ + { + "name": "@products", + "line": 14 + } + ], + "rows": [ + { + "cells": ["quantity", "product", "price"], + "line": 16 + }, + { + "cells": ["1", "Laptop", "$1000"], + "line": 17 + }, + { + "cells": ["2", "Mouse", "$40"], + "line": 18 + }, + { + "cells": ["3", "Keyboard", "$150"], + "line": 19 + } + ] + } + ] + } + ] + }, + { + "uri": "features/payment.feature", + "id": "payment-processing", + "keyword": "Feature", + "name": "Payment Processing", + "description": " Customers can pay using various methods", + "line": 1, + "tags": [ + { + "name": "@payment", + "line": 1 + } + ], + "elements": [ + { + "id": "payment-processing;payment-validation", + "keyword": "Rule", + "name": "Payment validation", + "description": " All payments must be validated before processing", + "line": 5, + "type": "rule", + "tags": [ + { + "name": "@validation", + "line": 4 + } + ], + "children": [ + { + "id": "payment-processing;payment-validation;background", + "keyword": "Background", + "name": "Setup payment environment", + "description": "", + "line": 8, + "type": "background", + "steps": [ + { + "keyword": "Given ", + "name": "the payment gateway is available", + "line": 9, + "match": { + "location": "step_definitions/payment_steps.js:5" + }, + "result": { + "status": "passed", + "duration": 1500000000 + } + } + ] + }, + { + "id": "payment-processing;payment-validation;valid-credit-card", + "keyword": "Scenario", + "name": "Valid credit card payment", + "description": "", + "line": 11, + "type": "scenario", + "tags": [ + { + "name": "@credit-card", + "line": 10 + } + ], + "steps": [ + { + "keyword": "When ", + "name": "I pay with a valid credit card", + "line": 12, + "match": { + "location": "step_definitions/payment_steps.js:10" + }, + "result": { + "status": "passed", + "duration": 3000000000 + } + }, + { + "keyword": "Then ", + "name": "the payment should be approved", + "line": 13, + "match": { + "location": "step_definitions/payment_steps.js:20" + }, + "result": { + "status": "passed", + "duration": 1000000000 + } + } + ] + } + ] + } + ] + } +] diff --git a/tests/test_data/XML/multiple_case_ids_with_attachments.xml b/tests/test_data/XML/multiple_case_ids_with_attachments.xml new file mode 100644 index 0000000..2f03ee5 --- /dev/null +++ b/tests/test_data/XML/multiple_case_ids_with_attachments.xml @@ -0,0 +1,86 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Expected: Payment successful +Actual: Payment failed with error code 500 +Stack trace: + at com.example.PaymentTest.testCheckout(PaymentTest.java:45) + at com.example.BaseTest.runTest(BaseTest.java:12) + + + + + diff --git a/tests/test_data/XML/testglob/junit-test-1.xml b/tests/test_data/XML/testglob/junit-test-1.xml new file mode 100644 index 0000000..8e0e771 --- /dev/null +++ b/tests/test_data/XML/testglob/junit-test-1.xml @@ -0,0 +1,37 @@ + + + + + + + Combined test for login scenarios: valid credentials, invalid password, locked account + + + + + Combined test for logout scenarios: normal logout, session timeout + + + + + + Expected: API endpoint should return expected response for all cases + Actual: API endpoint returned expected response for C1050394 and C1050395, but failed for C1050396 + + + + + + + Expected: Registration failed with error "Invalid credentials" + Actual: Registration succeeded without error + + + + + + Single test case using underscore format + + + + diff --git a/tests/test_data/XML/testglob/junit-test-2.xml b/tests/test_data/XML/testglob/junit-test-2.xml new file mode 100644 index 0000000..804a317 --- /dev/null +++ b/tests/test_data/XML/testglob/junit-test-2.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + + Expected: Login failed + Actual: Login succeeded + + + + diff --git a/tests/test_data/XML/testglob_robot/robot-1.xml b/tests/test_data/XML/testglob_robot/robot-1.xml new file mode 100644 index 0000000..1f78c70 --- /dev/null +++ b/tests/test_data/XML/testglob_robot/robot-1.xml @@ -0,0 +1,92 @@ + + + + + + SETUP + Logs the given message with the given level. + SETUP + + + + + OK + Logs the given message with the given level. + OK + + + + Custom test message + Sets message for the current test case. + Set test message to: + Custom test message + + + + Some documentation about my test Cases + Nothing to see here + + - testrail_case_id: C123 + - testrail_case_field: refs:TR-1 + - testrail_case_field: priority_id:2 + - testrail_result_field: custom_environment:qa + - testrail_result_field: custom_dropdown_1:3 + - testrail_result_comment: Notes for the result + - testrail_attachment: /reports/screenshot.png + + Custom test message + + + + + NOK + Fails the test with the given message and optionally alters its tags. + NOK + + + NOK + + + + + + + OK + Logs the given message with the given level. + OK + + + + + + + OK + Logs the given message with the given level. + OK + + + + + Simple homepage links tests + + + + + + + All Tests + + + + + Sub-Tests + Sub-Tests.Subtests 1 + Sub-Tests.Subtests 2 + + + + + diff --git a/tests/test_data/XML/testglob_robot/robot-2.xml b/tests/test_data/XML/testglob_robot/robot-2.xml new file mode 100644 index 0000000..000f3e1 --- /dev/null +++ b/tests/test_data/XML/testglob_robot/robot-2.xml @@ -0,0 +1,89 @@ + + + + + +SETUP +SETUP +Logs the given message with the given level. + + + + +OK +OK +Logs the given message with the given level. + + + +Set test message to: + Custom test message + +Custom test message +Sets message for the current test case. + + +Some documentation about my test Cases + Nothing to see here + + - testrail_case_id: C123 + - testrail_case_field: refs:TR-1 + - testrail_case_field: priority_id:2 + - testrail_result_field: custom_environment:qa + - testrail_result_field: custom_dropdown_1:3 + - testrail_result_comment: Notes for the result + - testrail_attachment: /reports/screenshot.png + +Custom test message + + + + +NOK +NOK +Fails the test with the given message and optionally alters its tags. + + +NOK + + + + + + +OK +OK +Logs the given message with the given level. + + + + + + +OK +OK +Logs the given message with the given level. + + + + +Simple homepage links tests + + + + + + +All Tests + + + + +Sub-Tests +Sub-Tests.Subtests 1 +Sub-Tests.Subtests 2 + + + + + diff --git a/tests/test_glob_deduplication.py b/tests/test_glob_deduplication.py new file mode 100644 index 0000000..89f1892 --- /dev/null +++ b/tests/test_glob_deduplication.py @@ -0,0 +1,380 @@ +""" +Unit tests for glob pattern deduplication logic. + +Tests verify that when glob patterns merge multiple files containing the same test +(same automation_id), the deduplication logic works correctly: +1. Only one test case is created (not duplicates) +2. All results are uploaded for that test case +""" + +import pytest +from pathlib import Path +from trcli.data_classes.dataclass_testrail import ( + TestRailSuite, + TestRailSection, + TestRailCase, + TestRailResult, +) +from trcli.data_providers.api_data_provider import ApiDataProvider + + +class TestGlobDeduplication: + """Tests for deduplication logic when glob patterns merge files with duplicate tests""" + + @pytest.mark.data_provider + def test_add_cases_deduplicates_by_automation_id(self): + """Test that add_cases() deduplicates test cases by automation_id. + + Scenario 1: Multiple XML files contain the same test (same automation_id). + Expected: Only ONE test case should be added, duplicates should be linked. + """ + # Create test data with duplicate automation_ids + section = TestRailSection( + name="Test Section", + section_id=1, + testcases=[ + TestRailCase( + title="Test Login", + case_id=None, # Not yet created + custom_automation_id="com.example.LoginTest.testLogin", + result=TestRailResult(case_id=None, status_id=1), # Passed + ), + TestRailCase( + title="Test Login", + case_id=None, # Not yet created (duplicate) + custom_automation_id="com.example.LoginTest.testLogin", # Same automation_id! + result=TestRailResult(case_id=None, status_id=5), # Failed + ), + TestRailCase( + title="Test Logout", + case_id=None, + custom_automation_id="com.example.LoginTest.testLogout", # Different test + result=TestRailResult(case_id=None, status_id=1), + ), + ], + ) + + suite = TestRailSuite(name="Test Suite", suite_id=1, testsections=[section]) + data_provider = ApiDataProvider(suite) + + # Call add_cases() which should deduplicate + cases_to_add = data_provider.add_cases() + + # Verify: Only 2 cases should be in the list (not 3) + # One for testLogin (not the duplicate) and one for testLogout + assert len(cases_to_add) == 2, f"Expected 2 cases, got {len(cases_to_add)}" + + # Verify the automation_ids are unique + automation_ids = [case.custom_automation_id for case in cases_to_add] + assert len(automation_ids) == len(set(automation_ids)), "Automation IDs should be unique" + assert "com.example.LoginTest.testLogin" in automation_ids + assert "com.example.LoginTest.testLogout" in automation_ids + + # Verify the first testLogin case has _duplicates attribute + test_login_case = next( + case for case in cases_to_add if case.custom_automation_id == "com.example.LoginTest.testLogin" + ) + assert hasattr(test_login_case, "_duplicates"), "Master case should have _duplicates attribute" + assert len(test_login_case._duplicates) == 1, "Should have 1 duplicate linked" + + @pytest.mark.data_provider + def test_add_cases_preserves_all_results_via_deduplication(self): + """Test that duplicate results are preserved through linking. + + Scenario 2: After deduplication, verify that ALL test cases still exist + in the original suite (they're just not in add_cases() list). + This ensures add_results_for_cases() can still find them. + """ + # Create test data with duplicate automation_ids but different results + section = TestRailSection( + name="Test Section", + section_id=1, + testcases=[ + TestRailCase( + title="Test Payment", + case_id=None, + custom_automation_id="com.example.PaymentTest.testPayment", + result=TestRailResult(case_id=None, status_id=1, comment="Passed in file1.xml"), + ), + TestRailCase( + title="Test Payment", + case_id=None, + custom_automation_id="com.example.PaymentTest.testPayment", + result=TestRailResult(case_id=None, status_id=5, comment="Failed in file2.xml"), + ), + TestRailCase( + title="Test Payment", + case_id=None, + custom_automation_id="com.example.PaymentTest.testPayment", + result=TestRailResult(case_id=None, status_id=4, comment="Skipped in file3.xml"), + ), + ], + ) + + suite = TestRailSuite(name="Test Suite", suite_id=1, testsections=[section]) + data_provider = ApiDataProvider(suite) + + # Verify suite still contains all 3 test cases (not modified by add_cases) + assert len(suite.testsections[0].testcases) == 3, "Original suite should still have all 3 testcases" + + # Call add_cases() - should return only 1 case + cases_to_add = data_provider.add_cases() + assert len(cases_to_add) == 1, "Should only add 1 case (master)" + + # Verify the master case has 2 duplicates linked + master_case = cases_to_add[0] + assert hasattr(master_case, "_duplicates"), "Master should have _duplicates" + assert len(master_case._duplicates) == 2, "Should have 2 duplicates linked" + + # Verify the duplicates are the other 2 cases with different results + duplicate_comments = [dup.result.comment for dup in master_case._duplicates] + assert "Failed in file2.xml" in duplicate_comments + assert "Skipped in file3.xml" in duplicate_comments + + @pytest.mark.data_provider + def test_add_cases_no_deduplication_without_automation_id(self): + """Test that cases without automation_id are not deduplicated.""" + section = TestRailSection( + name="Test Section", + section_id=1, + testcases=[ + TestRailCase( + title="Test A", + case_id=None, + custom_automation_id=None, # No automation_id + result=TestRailResult(case_id=None, status_id=1), + ), + TestRailCase( + title="Test A", + case_id=None, + custom_automation_id=None, # No automation_id (duplicate title) + result=TestRailResult(case_id=None, status_id=5), + ), + ], + ) + + suite = TestRailSuite(name="Test Suite", suite_id=1, testsections=[section]) + data_provider = ApiDataProvider(suite) + + cases_to_add = data_provider.add_cases() + + # Without automation_id, both cases should be added (no deduplication) + assert len(cases_to_add) == 2, "Cases without automation_id should not be deduplicated" + + @pytest.mark.data_provider + def test_add_results_includes_all_cases_with_case_id(self): + """Test that add_results_for_cases() includes all cases with case_id set. + + This verifies Scenario 2 fix: After case_id propagation to duplicates, + all results should be uploaded (not just the master). + """ + # Simulate after case creation: master and duplicates all have case_id set + section = TestRailSection( + name="Test Section", + section_id=1, + testcases=[ + TestRailCase( + title="Test Login", + case_id=101, # Case created + custom_automation_id="com.example.LoginTest.testLogin", + result=TestRailResult(case_id=101, status_id=1, comment="Run 1: Passed"), + ), + TestRailCase( + title="Test Login", + case_id=101, # Duplicate has same case_id (propagated) + custom_automation_id="com.example.LoginTest.testLogin", + result=TestRailResult(case_id=101, status_id=5, comment="Run 2: Failed"), + ), + TestRailCase( + title="Test Login", + case_id=101, # Another duplicate + custom_automation_id="com.example.LoginTest.testLogin", + result=TestRailResult(case_id=101, status_id=1, comment="Run 3: Passed"), + ), + ], + ) + + suite = TestRailSuite(name="Test Suite", suite_id=1, testsections=[section]) + data_provider = ApiDataProvider(suite) + + # Call add_results_for_cases() - should return ALL 3 results (not deduplicated) + result_chunks = data_provider.add_results_for_cases(bulk_size=10) + all_results = [] + for chunk in result_chunks: + all_results.extend(chunk["results"]) + + # Verify: All 3 results should be included + assert len(all_results) == 3, f"Expected 3 results, got {len(all_results)}" + + # Verify all have the same case_id + case_ids = [result["case_id"] for result in all_results] + assert all(cid == 101 for cid in case_ids), "All results should have case_id=101" + + # Verify all 3 comments are present + comments = [result["comment"] for result in all_results] + assert "Run 1: Passed" in comments + assert "Run 2: Failed" in comments + assert "Run 3: Passed" in comments + + @pytest.mark.data_provider + def test_add_cases_multiple_duplicates_same_automation_id(self): + """Test deduplication with many duplicates of the same test.""" + # Create 5 test cases with the same automation_id + testcases = [ + TestRailCase( + title=f"Test API Call #{i}", + case_id=None, + custom_automation_id="com.example.APITest.testGetUser", # Same for all + result=TestRailResult(case_id=None, status_id=1), + ) + for i in range(5) + ] + + section = TestRailSection(name="Test Section", section_id=1, testcases=testcases) + suite = TestRailSuite(name="Test Suite", suite_id=1, testsections=[section]) + data_provider = ApiDataProvider(suite) + + cases_to_add = data_provider.add_cases() + + # Should only add 1 case + assert len(cases_to_add) == 1, f"Expected 1 case, got {len(cases_to_add)}" + + # Should have 4 duplicates linked + master_case = cases_to_add[0] + assert hasattr(master_case, "_duplicates") + assert len(master_case._duplicates) == 4, "Should have 4 duplicates" + + @pytest.mark.data_provider + def test_add_cases_mixed_duplicates_and_unique(self): + """Test deduplication with mix of duplicate and unique tests.""" + section = TestRailSection( + name="Test Section", + section_id=1, + testcases=[ + # Duplicate group 1: testLogin appears 2 times + TestRailCase( + title="Test Login", + case_id=None, + custom_automation_id="LoginTest.testLogin", + result=TestRailResult(case_id=None, status_id=1), + ), + TestRailCase( + title="Test Login", + case_id=None, + custom_automation_id="LoginTest.testLogin", + result=TestRailResult(case_id=None, status_id=5), + ), + # Unique test + TestRailCase( + title="Test Register", + case_id=None, + custom_automation_id="LoginTest.testRegister", + result=TestRailResult(case_id=None, status_id=1), + ), + # Duplicate group 2: testLogout appears 3 times + TestRailCase( + title="Test Logout", + case_id=None, + custom_automation_id="LoginTest.testLogout", + result=TestRailResult(case_id=None, status_id=1), + ), + TestRailCase( + title="Test Logout", + case_id=None, + custom_automation_id="LoginTest.testLogout", + result=TestRailResult(case_id=None, status_id=4), + ), + TestRailCase( + title="Test Logout", + case_id=None, + custom_automation_id="LoginTest.testLogout", + result=TestRailResult(case_id=None, status_id=1), + ), + ], + ) + + suite = TestRailSuite(name="Test Suite", suite_id=1, testsections=[section]) + data_provider = ApiDataProvider(suite) + + cases_to_add = data_provider.add_cases() + + # Should add 3 unique cases: testLogin, testRegister, testLogout + assert len(cases_to_add) == 3, f"Expected 3 cases, got {len(cases_to_add)}" + + automation_ids = [case.custom_automation_id for case in cases_to_add] + assert "LoginTest.testLogin" in automation_ids + assert "LoginTest.testRegister" in automation_ids + assert "LoginTest.testLogout" in automation_ids + + # Verify duplicate counts + login_case = next(c for c in cases_to_add if c.custom_automation_id == "LoginTest.testLogin") + logout_case = next(c for c in cases_to_add if c.custom_automation_id == "LoginTest.testLogout") + register_case = next(c for c in cases_to_add if c.custom_automation_id == "LoginTest.testRegister") + + assert len(login_case._duplicates) == 1, "testLogin should have 1 duplicate" + assert len(logout_case._duplicates) == 2, "testLogout should have 2 duplicates" + assert not hasattr(register_case, "_duplicates"), "testRegister should have no duplicates" + + @pytest.mark.data_provider + def test_update_case_data_updates_all_duplicates(self): + """Test that __update_case_data() updates ALL cases with matching automation_id. + + This is the fix for Scenario 2: When cases already exist in TestRail (-n flag), + and glob merges multiple files with the same test, ALL instances should get the case_id assigned. + """ + # Create suite with duplicate automation_ids (from glob merging) + section = TestRailSection( + name="Test Section", + section_id=1, + testcases=[ + TestRailCase( + title="Test Login", + case_id=None, # Not assigned yet + custom_automation_id="com.example.LoginTest.testLogin", + result=TestRailResult(case_id=None, status_id=1, comment="Run 1"), + ), + TestRailCase( + title="Test Login", + case_id=None, # Not assigned yet (duplicate) + custom_automation_id="com.example.LoginTest.testLogin", # Same! + result=TestRailResult(case_id=None, status_id=5, comment="Run 2"), + ), + TestRailCase( + title="Test Login", + case_id=None, # Not assigned yet (duplicate) + custom_automation_id="com.example.LoginTest.testLogin", # Same! + result=TestRailResult(case_id=None, status_id=1, comment="Run 3"), + ), + ], + ) + + suite = TestRailSuite(name="Test Suite", suite_id=1, testsections=[section]) + data_provider = ApiDataProvider(suite) + + # Simulate case matcher finding existing case in TestRail + case_data = [ + { + "case_id": 999, + "section_id": 1, + "title": "Test Login", + "custom_automation_id": "com.example.LoginTest.testLogin", + } + ] + + # Call update_data (this is what case matcher does) + data_provider.update_data(case_data=case_data) + + # Verify ALL three test cases got the case_id assigned (not just first one) + testcases = section.testcases + assert len(testcases) == 3, "Should have 3 test cases" + + for i, testcase in enumerate(testcases): + assert testcase.case_id == 999, f"Test case {i+1} should have case_id=999 from matcher" + assert testcase.result.case_id == 999, f"Test case {i+1} result should have case_id=999" + assert testcase.section_id == 1, f"Test case {i+1} should have section_id=1" + + # Verify comments are preserved (each test case keeps its unique result) + comments = [tc.result.comment for tc in testcases] + assert "Run 1" in comments + assert "Run 2" in comments + assert "Run 3" in comments diff --git a/tests/test_glob_integration.py b/tests/test_glob_integration.py new file mode 100644 index 0000000..81aa056 --- /dev/null +++ b/tests/test_glob_integration.py @@ -0,0 +1,285 @@ +""" +Integration tests for glob pattern scenarios with deduplication. + +These tests verify the end-to-end behavior described in the user scenarios: +- Scenario 1: Duplicate automation_ids should create only one case +- Scenario 2: Multiple results for same test should all be uploaded +- Scenario 3: Cucumber glob with BDD auto-creation should work +""" + +import pytest +import json +from pathlib import Path +from unittest.mock import Mock, MagicMock, patch +from trcli.cli import Environment +from trcli.data_classes.data_parsers import MatchersParser +from trcli.readers.junit_xml import JunitParser +from trcli.readers.robot_xml import RobotParser +from trcli.readers.cucumber_json import CucumberParser +from trcli.data_providers.api_data_provider import ApiDataProvider + + +class TestGlobIntegration: + """Integration tests for glob pattern scenarios""" + + @pytest.mark.parse_junit + def test_glob_junit_duplicate_automation_ids_scenario_1(self): + """Test Scenario 1: Multiple XML files with same automation_id create only one case. + + Setup: + - Two JUnit XML files (merged via glob pattern) + - Both contain com.example.LoginTest.testLogin + - Cases are NOT yet in TestRail (case_id=None) + + Expected: + - Only 1 test case should be added to TestRail + - Both results should be available for upload + """ + # This test verifies the glob merging creates duplicates + # and that add_cases() deduplicates them correctly + env = Environment() + env.case_matcher = MatchersParser.AUTO + + # Use glob pattern that matches multiple files + env.file = Path(__file__).parent / "test_data/XML/testglob/*.xml" + + parser = JunitParser(env) + parsed_suites = parser.parse_file() + + # Verify we got results from merged file + assert len(parsed_suites) == 1 + suite = parsed_suites[0] + + # Collect all automation_ids from parsed results + automation_ids = [] + for section in suite.testsections: + for testcase in section.testcases: + if testcase.custom_automation_id: + automation_ids.append(testcase.custom_automation_id) + + # If there are duplicates (same automation_id appears multiple times) + if len(automation_ids) != len(set(automation_ids)): + # Create data provider and call add_cases() + data_provider = ApiDataProvider(suite) + cases_to_add = data_provider.add_cases() + + # The number of cases to add should be less than total cases + # (because duplicates are filtered out) + total_cases = sum(len(section.testcases) for section in suite.testsections) + assert len(cases_to_add) < total_cases, "Deduplication should reduce number of cases to add" + + # Verify unique automation_ids + added_automation_ids = [c.custom_automation_id for c in cases_to_add if c.custom_automation_id] + assert len(added_automation_ids) == len( + set(added_automation_ids) + ), "Cases to add should have unique automation_ids" + + # Clean up merged file + merged_file = Path.cwd() / "Merged-JUnit-report.xml" + if merged_file.exists(): + merged_file.unlink() + + @pytest.mark.parse_junit + def test_glob_junit_multiple_results_scenario_2(self): + """Test Scenario 2: Same test with different results uploads all results. + + Setup: + - Multiple XML files with same test (same automation_id) + - One file: test PASSED + - Another file: test FAILED + - Cases already exist in TestRail (case_id is set) + + Expected: + - Both results should be included in add_results_for_cases() + - The test run should contain 2 result entries for the same case + """ + env = Environment() + env.case_matcher = MatchersParser.AUTO + env.file = Path(__file__).parent / "test_data/XML/testglob/*.xml" + + parser = JunitParser(env) + parsed_suites = parser.parse_file() + suite = parsed_suites[0] + + # Simulate: Cases already exist in TestRail (matcher found them) + # Set case_id for all testcases that have the same automation_id + # This simulates the case matcher assigning case_ids + automation_id_to_case_id = {} + next_case_id = 1001 + + for section in suite.testsections: + for testcase in section.testcases: + if testcase.custom_automation_id: + # If we've seen this automation_id before, reuse the case_id + if testcase.custom_automation_id not in automation_id_to_case_id: + automation_id_to_case_id[testcase.custom_automation_id] = next_case_id + next_case_id += 1 + + testcase.case_id = automation_id_to_case_id[testcase.custom_automation_id] + testcase.result.case_id = testcase.case_id + + # Now get results for upload + data_provider = ApiDataProvider(suite) + result_chunks = data_provider.add_results_for_cases(bulk_size=100) + + all_results = [] + for chunk in result_chunks: + all_results.extend(chunk["results"]) + + # Check if there are duplicate case_ids in results (multiple results for same case) + case_ids_in_results = [r["case_id"] for r in all_results] + + # If a case_id appears more than once, Scenario 2 is verified + case_id_counts = {} + for case_id in case_ids_in_results: + case_id_counts[case_id] = case_id_counts.get(case_id, 0) + 1 + + # At least one case should have multiple results + multiple_results = [cid for cid, count in case_id_counts.items() if count > 1] + + if len(automation_id_to_case_id) < sum(len(s.testcases) for s in suite.testsections): + # We had duplicates, so we should have multiple results for at least one case + assert len(multiple_results) > 0, "Cases with duplicate automation_ids should have multiple results" + + # Clean up merged file + merged_file = Path.cwd() / "Merged-JUnit-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). + + This verifies the fix for Scenario 3 where parser.filepath is used + instead of environment.file when loading JSON for BDD auto-creation. + """ + env = Environment() + env.case_matcher = MatchersParser.AUTO + env.file = Path(__file__).parent / "test_data/CUCUMBER/testglob/*.json" + + # Check if test files exist + test_files = list(Path(__file__).parent.glob("test_data/CUCUMBER/testglob/*.json")) + if not test_files: + pytest.skip("Cucumber test data not available") + + parser = CucumberParser(env) + + # The key assertion: parser.filepath should be the actual file path, + # not the glob pattern string + assert parser.filepath != env.file, "parser.filepath should be resolved file, not glob pattern" + + assert parser.filepath.exists(), f"parser.filepath should point to existing file: {parser.filepath}" + + # Verify we can open the file (this was failing in Scenario 3) + try: + with open(parser.filepath, "r", encoding="utf-8") as f: + data = json.load(f) + assert isinstance(data, list), "Cucumber JSON should be array" + except FileNotFoundError as e: + pytest.fail(f"Failed to open parser.filepath: {e}") + + # Clean up merged file + merged_file = Path.cwd() / "Merged-Cucumber-report.json" + if merged_file.exists(): + merged_file.unlink() + + @pytest.mark.parse_junit + def test_case_id_propagation_to_duplicates(self): + """Test that case_id is propagated from master to duplicate cases after creation. + + This verifies the fix in case_handler.py where _add_case_and_update_data + propagates case_id to all linked duplicates. + """ + from trcli.data_classes.dataclass_testrail import TestRailCase, TestRailResult, TestRailSection, TestRailSuite + + # Create master case with duplicates + master_case = TestRailCase( + title="Test Login", + case_id=None, + custom_automation_id="LoginTest.testLogin", + result=TestRailResult(case_id=None, status_id=1), + ) + + duplicate1 = TestRailCase( + title="Test Login", + case_id=None, + custom_automation_id="LoginTest.testLogin", + result=TestRailResult(case_id=None, status_id=5), + ) + + duplicate2 = TestRailCase( + title="Test Login", + case_id=None, + custom_automation_id="LoginTest.testLogin", + result=TestRailResult(case_id=None, status_id=1), + ) + + # Link duplicates to master (this is done by add_cases()) + master_case._duplicates = [duplicate1, duplicate2] + + # Simulate case creation (what _add_case_and_update_data does) + created_case_id = 12345 + master_case.case_id = created_case_id + master_case.result.case_id = created_case_id + + # Propagate to duplicates (the fix we added) + if hasattr(master_case, "_duplicates"): + for duplicate_case in master_case._duplicates: + duplicate_case.case_id = created_case_id + duplicate_case.result.case_id = created_case_id + + # Verify all cases now have the same case_id + assert master_case.case_id == 12345 + assert duplicate1.case_id == 12345 + assert duplicate2.case_id == 12345 + assert master_case.result.case_id == 12345 + assert duplicate1.result.case_id == 12345 + assert duplicate2.result.case_id == 12345 + + @pytest.mark.parse_junit + def test_add_cases_preserves_section_structure(self): + """Test that deduplication preserves section structure. + + Verify that duplicate cases across different sections are handled correctly. + """ + from trcli.data_classes.dataclass_testrail import TestRailCase, TestRailResult, TestRailSection, TestRailSuite + + # Create suite with 2 sections, each containing the same test + section1 = TestRailSection( + name="Section 1", + section_id=1, + testcases=[ + TestRailCase( + title="Test Login", + case_id=None, + custom_automation_id="LoginTest.testLogin", + result=TestRailResult(case_id=None, status_id=1), + ) + ], + ) + + section2 = TestRailSection( + name="Section 2", + section_id=2, + testcases=[ + TestRailCase( + title="Test Login", + case_id=None, + custom_automation_id="LoginTest.testLogin", # Same test in different section + result=TestRailResult(case_id=None, status_id=5), + ) + ], + ) + + suite = TestRailSuite(name="Test Suite", suite_id=1, testsections=[section1, section2]) + data_provider = ApiDataProvider(suite) + + cases_to_add = data_provider.add_cases() + + # Should only add 1 case (deduplicated across sections) + assert len(cases_to_add) == 1, "Should deduplicate across sections" + + # The master case should have 1 duplicate + master_case = cases_to_add[0] + assert hasattr(master_case, "_duplicates") + assert len(master_case._duplicates) == 1 diff --git a/tests/test_junit_parser.py b/tests/test_junit_parser.py index 7c2e107..43e7cb1 100644 --- a/tests/test_junit_parser.py +++ b/tests/test_junit_parser.py @@ -30,8 +30,8 @@ class TestJunitParser: Path(__file__).parent / "test_data/json/root.json", ), ( - Path(__file__).parent / "test_data/XML/ro*t.xml", - Path(__file__).parent / "test_data/json/root.json", + Path(__file__).parent / "test_data/XML/ro*t.xml", + Path(__file__).parent / "test_data/json/root.json", ), ( Path(__file__).parent / "test_data/XML/required_only.xml", @@ -40,7 +40,7 @@ class TestJunitParser: ( Path(__file__).parent / "test_data/XML/custom_automation_id_in_property.xml", Path(__file__).parent / "test_data/json/custom_automation_id_in_property.json", - ) + ), ], ids=[ "XML without testsuites root", @@ -51,9 +51,7 @@ class TestJunitParser: ], ) @pytest.mark.parse_junit - def test_junit_xml_parser_valid_files( - self, input_xml_path: Union[str, Path], expected_path: str, freezer - ): + def test_junit_xml_parser_valid_files(self, input_xml_path: Union[str, Path], expected_path: str, freezer): freezer.move_to("2020-05-20 01:00:00") env = Environment() env.case_matcher = MatchersParser.AUTO @@ -64,8 +62,9 @@ def test_junit_xml_parser_valid_files( print(parsing_result_json) file_json = open(expected_path) expected_json = json.load(file_json) - assert DeepDiff(parsing_result_json, expected_json) == {}, \ - f"Result of parsing Junit XML is different than expected \n{DeepDiff(parsing_result_json, expected_json)}" + assert ( + DeepDiff(parsing_result_json, expected_json) == {} + ), f"Result of parsing Junit XML is different than expected \n{DeepDiff(parsing_result_json, expected_json)}" @pytest.mark.parse_junit def test_junit_xml_elapsed_milliseconds(self, freezer): @@ -80,8 +79,9 @@ def test_junit_xml_elapsed_milliseconds(self, freezer): parsing_result_json = asdict(read_junit) file_json = open(Path(__file__).parent / "test_data/json/milliseconds.json") expected_json = json.load(file_json) - assert DeepDiff(parsing_result_json, expected_json) == {}, \ - f"Result of parsing Junit XML is different than expected \n{DeepDiff(parsing_result_json, expected_json)}" + assert ( + DeepDiff(parsing_result_json, expected_json) == {} + ), f"Result of parsing Junit XML is different than expected \n{DeepDiff(parsing_result_json, expected_json)}" @pytest.mark.parse_junit def test_junit_xml_parser_sauce(self, freezer): @@ -90,8 +90,10 @@ def _compare(junit_output, expected_path): parsing_result_json = asdict(read_junit) file_json = open(expected_path) expected_json = json.load(file_json) - assert DeepDiff(parsing_result_json, expected_json) == {}, \ - f"Result of parsing Junit XML is different than expected \n{DeepDiff(parsing_result_json, expected_json)}" + assert ( + DeepDiff(parsing_result_json, expected_json) == {} + ), f"Result of parsing Junit XML is different than expected \n{DeepDiff(parsing_result_json, expected_json)}" + freezer.move_to("2020-05-20 01:00:00") env = Environment() env.case_matcher = MatchersParser.AUTO @@ -99,29 +101,35 @@ def _compare(junit_output, expected_path): env.special_parser = "saucectl" file_reader = JunitParser(env) junit_outputs = file_reader.parse_file() - _compare(junit_outputs[0], Path(__file__).parent / "test_data/json/sauce1.json",) - _compare(junit_outputs[1], Path(__file__).parent / "test_data/json/sauce2.json", ) + _compare( + junit_outputs[0], + Path(__file__).parent / "test_data/json/sauce1.json", + ) + _compare( + junit_outputs[1], + Path(__file__).parent / "test_data/json/sauce2.json", + ) @pytest.mark.parse_junit @pytest.mark.parametrize( "matcher, input_xml_path, expected_path", [ ( - MatchersParser.NAME, - Path(__file__).parent / "test_data/XML/root_id_in_name.xml", - Path(__file__).parent / "test_data/json/root_id_in_name.json", + MatchersParser.NAME, + Path(__file__).parent / "test_data/XML/root_id_in_name.xml", + Path(__file__).parent / "test_data/json/root_id_in_name.json", ), ( - MatchersParser.PROPERTY, - Path(__file__).parent / "test_data/XML/root_id_in_property.xml", - Path(__file__).parent / "test_data/json/root_id_in_property.json", - ) + MatchersParser.PROPERTY, + Path(__file__).parent / "test_data/XML/root_id_in_property.xml", + Path(__file__).parent / "test_data/json/root_id_in_property.json", + ), ], ids=["Case Matcher Name", "Case Matcher Property"], ) @pytest.mark.parse_junit def test_junit_xml_parser_id_matcher_name( - self, matcher: str, input_xml_path: Union[str, Path], expected_path: str, freezer + self, matcher: str, input_xml_path: Union[str, Path], expected_path: str, freezer ): freezer.move_to("2020-05-20 01:00:00") env = Environment() @@ -132,8 +140,9 @@ def test_junit_xml_parser_id_matcher_name( parsing_result_json = asdict(read_junit) file_json = open(expected_path) expected_json = json.load(file_json) - assert DeepDiff(parsing_result_json, expected_json) == {}, \ - f"Result of parsing Junit XML is different than expected \n{DeepDiff(parsing_result_json, expected_json)}" + assert ( + DeepDiff(parsing_result_json, expected_json) == {} + ), f"Result of parsing Junit XML is different than expected \n{DeepDiff(parsing_result_json, expected_json)}" @pytest.mark.parse_junit def test_junit_xml_parser_invalid_file(self): @@ -166,15 +175,131 @@ def test_junit_xml_parser_validation_error(self): with pytest.raises(ValidationException): file_reader.parse_file() - def __clear_unparsable_junit_elements( - self, test_rail_suite: TestRailSuite - ) -> TestRailSuite: + @pytest.mark.parse_junit + def test_junit_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/root.xml" + + # This should work just like a regular file path + file_reader = JunitParser(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_junit + def test_junit_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 JUnit XML files + env.file = Path(__file__).parent / "test_data/XML/testglob/*.xml" + + file_reader = JunitParser(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-JUnit-report.xml" + assert merged_file.exists(), "Merged JUnit 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_junit + def test_junit_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" + JunitParser(env) + + @pytest.mark.parse_junit + def test_junit_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/root.xml" + result = JunitParser.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/*.xml" + result = JunitParser.check_file(multi_file_glob) + assert isinstance(result, Path) + assert result.name == "Merged-JUnit-report.xml" + assert result.exists() + + # Verify merged file contains valid XML + from xml.etree import ElementTree + + tree = ElementTree.parse(result) + root = tree.getroot() + assert root.tag == "testsuites", "Merged file should have testsuites root" + + # Clean up + if result.exists() and result.name == "Merged-JUnit-report.xml": + result.unlink() + + @pytest.mark.parse_junit + def test_junit_xml_parser_glob_pattern_merges_content(self): + """Test that glob pattern properly merges content from multiple files""" + env = Environment() + env.case_matcher = MatchersParser.AUTO + # Use glob pattern that matches multiple files + env.file = Path(__file__).parent / "test_data/XML/testglob/*.xml" + + file_reader = JunitParser(env) + result = file_reader.parse_file() + + # Count total test cases across all sections + total_cases = sum(len(section.testcases) for section in result[0].testsections) + + # Parse individual files to compare + env1 = Environment() + env1.case_matcher = MatchersParser.AUTO + env1.file = Path(__file__).parent / "test_data/XML/testglob/junit-test-1.xml" + result1 = JunitParser(env1).parse_file() + cases1 = sum(len(section.testcases) for section in result1[0].testsections) + + env2 = Environment() + env2.case_matcher = MatchersParser.AUTO + env2.file = Path(__file__).parent / "test_data/XML/testglob/junit-test-2.xml" + result2 = JunitParser(env2).parse_file() + cases2 = sum(len(section.testcases) for section in result2[0].testsections) + + # Merged result should contain all test cases from both files + assert ( + total_cases == cases1 + cases2 + ), f"Merged result should contain {cases1 + cases2} cases, but got {total_cases}" + + # Clean up merged file + merged_file = Path.cwd() / "Merged-JUnit-report.xml" + if merged_file.exists(): + merged_file.unlink() + + def __clear_unparsable_junit_elements(self, test_rail_suite: TestRailSuite) -> TestRailSuite: """helper method to delete junit_result_unparsed field and temporary junit_case_refs attribute, which asdict() method of dataclass can't handle""" for section in test_rail_suite.testsections: for case in section.testcases: case.result.junit_result_unparsed = [] # Remove temporary junit_case_refs attribute if it exists - if hasattr(case, '_junit_case_refs'): - delattr(case, '_junit_case_refs') + if hasattr(case, "_junit_case_refs"): + delattr(case, "_junit_case_refs") return test_rail_suite diff --git a/tests/test_multiple_case_ids.py b/tests/test_multiple_case_ids.py index 433ce7e..c61ac05 100644 --- a/tests/test_multiple_case_ids.py +++ b/tests/test_multiple_case_ids.py @@ -275,3 +275,247 @@ def test_multiple_case_ids_all_get_same_result(self, mock_environment): # All should have same automation_id automation_ids = [tc.custom_automation_id for tc in combined_cases] assert len(set(automation_ids)) == 1, "All cases should have the same automation_id" + + def test_multiple_case_ids_attachments_duplicated(self, mock_environment): + """Verify that attachments are duplicated for each case ID""" + mock_environment.file = "tests/test_data/XML/multiple_case_ids_with_attachments.xml" + parser = JunitParser(mock_environment) + suites = parser.parse_file() + + # Get test cases for C1050400, C1050401, C1050402 (test with attachments) + all_test_cases = [] + for suite in suites: + for section in suite.testsections: + all_test_cases.extend(section.testcases) + + attachment_cases = [tc for tc in all_test_cases if tc.case_id in [1050400, 1050401, 1050402]] + assert len(attachment_cases) == 3, "Should have 3 test cases with attachments" + + # Verify all cases have the same attachments + for case in attachment_cases: + assert len(case.result.attachments) == 3, f"Case {case.case_id} should have 3 attachments" + assert "/path/to/screenshot.png" in case.result.attachments + assert "/path/to/log.txt" in case.result.attachments + assert "/path/to/video.mp4" in case.result.attachments + + # Verify attachment lists are independent (different list objects) + assert attachment_cases[0].result.attachments is not attachment_cases[1].result.attachments + assert attachment_cases[0].result.attachments is not attachment_cases[2].result.attachments + assert attachment_cases[1].result.attachments is not attachment_cases[2].result.attachments + + def test_multiple_case_ids_result_fields_duplicated(self, mock_environment): + """Verify that result fields are duplicated for each case ID""" + mock_environment.file = "tests/test_data/XML/multiple_case_ids_with_attachments.xml" + parser = JunitParser(mock_environment) + suites = parser.parse_file() + + # Get test cases for C1050410, C1050411, C1050412 (test with result fields) + all_test_cases = [] + for suite in suites: + for section in suite.testsections: + all_test_cases.extend(section.testcases) + + result_field_cases = [tc for tc in all_test_cases if tc.case_id in [1050410, 1050411, 1050412]] + assert len(result_field_cases) == 3, "Should have 3 test cases with result fields" + + # Verify all cases have the same result fields + for case in result_field_cases: + assert "version" in case.result.result_fields + assert case.result.result_fields["version"] == "1.2.3" + assert "environment" in case.result.result_fields + assert case.result.result_fields["environment"] == "staging" + assert "browser" in case.result.result_fields + assert case.result.result_fields["browser"] == "chrome" + + # Verify result_fields dicts are independent (different dict objects) + assert result_field_cases[0].result.result_fields is not result_field_cases[1].result.result_fields + assert result_field_cases[0].result.result_fields is not result_field_cases[2].result.result_fields + assert result_field_cases[1].result.result_fields is not result_field_cases[2].result.result_fields + + def test_multiple_case_ids_case_fields_duplicated(self, mock_environment): + """Verify that case fields are duplicated for each case ID""" + mock_environment.file = "tests/test_data/XML/multiple_case_ids_with_attachments.xml" + parser = JunitParser(mock_environment) + suites = parser.parse_file() + + # Get test cases for C1050420, C1050421 (test with case fields) + all_test_cases = [] + for suite in suites: + for section in suite.testsections: + all_test_cases.extend(section.testcases) + + case_field_cases = [tc for tc in all_test_cases if tc.case_id in [1050420, 1050421]] + assert len(case_field_cases) == 2, "Should have 2 test cases with case fields" + + # Verify all cases have the same case fields + for case in case_field_cases: + assert "custom_preconds" in case.case_fields + assert case.case_fields["custom_preconds"] == "Setup database and test users" + assert "custom_automation_type" in case.case_fields + assert case.case_fields["custom_automation_type"] == "e2e" + assert "custom_steps" in case.case_fields + assert "1. Login to application" in case.case_fields["custom_steps"] + assert "2. Navigate to dashboard" in case.case_fields["custom_steps"] + + # Verify case_fields dicts are independent (different dict objects) + assert case_field_cases[0].case_fields is not case_field_cases[1].case_fields + + def test_multiple_case_ids_step_results_duplicated(self, mock_environment): + """Verify that step results are duplicated for each case ID""" + mock_environment.file = "tests/test_data/XML/multiple_case_ids_with_attachments.xml" + parser = JunitParser(mock_environment) + suites = parser.parse_file() + + # Get test cases for C1050430, C1050431, C1050432, C1050433 (test with step results) + all_test_cases = [] + for suite in suites: + for section in suite.testsections: + all_test_cases.extend(section.testcases) + + step_result_cases = [tc for tc in all_test_cases if tc.case_id in [1050430, 1050431, 1050432, 1050433]] + assert len(step_result_cases) == 4, "Should have 4 test cases with step results" + + # Verify all cases have the same step results + for case in step_result_cases: + assert len(case.result.custom_step_results) == 3, f"Case {case.case_id} should have 3 step results" + + # Verify step content and statuses + assert case.result.custom_step_results[0].content == "Login successful" + assert case.result.custom_step_results[0].status_id == 1 # passed + + assert case.result.custom_step_results[1].content == "Navigate to checkout" + assert case.result.custom_step_results[1].status_id == 1 # passed + + assert case.result.custom_step_results[2].content == "Payment processing failed" + assert case.result.custom_step_results[2].status_id == 5 # failed + + # Verify step result lists are independent (different list objects) + assert step_result_cases[0].result.custom_step_results is not step_result_cases[1].result.custom_step_results + assert step_result_cases[0].result.custom_step_results is not step_result_cases[2].result.custom_step_results + + def test_multiple_case_ids_all_features_combined_passing(self, mock_environment): + """Verify all features work together for passing tests""" + mock_environment.file = "tests/test_data/XML/multiple_case_ids_with_attachments.xml" + parser = JunitParser(mock_environment) + suites = parser.parse_file() + + # Get test cases for C1050440, C1050441, C1050442 (kitchen sink passing test) + all_test_cases = [] + for suite in suites: + for section in suite.testsections: + all_test_cases.extend(section.testcases) + + kitchen_sink_cases = [tc for tc in all_test_cases if tc.case_id in [1050440, 1050441, 1050442]] + assert len(kitchen_sink_cases) == 3, "Should have 3 test cases for kitchen sink test" + + for case in kitchen_sink_cases: + # Verify status + assert case.result.status_id == 1, f"Case {case.case_id} should be passed" + + # Verify attachments + assert len(case.result.attachments) == 2 + assert "/evidence/test_screenshot.png" in case.result.attachments + assert "/evidence/debug.log" in case.result.attachments + + # Verify result fields + assert case.result.result_fields["version"] == "2.0.0" + assert case.result.result_fields["browser"] == "firefox" + + # Verify case fields + assert case.case_fields["custom_automation_type"] == "integration" + + # Verify comment + assert "Full integration test executed successfully" in case.result.comment + + # Verify step results + assert len(case.result.custom_step_results) == 2 + assert case.result.custom_step_results[0].content == "Setup complete" + assert case.result.custom_step_results[0].status_id == 1 + + def test_multiple_case_ids_all_features_combined_failing(self, mock_environment): + """Verify all features work together for failing tests (failure info + attachments)""" + mock_environment.file = "tests/test_data/XML/multiple_case_ids_with_attachments.xml" + parser = JunitParser(mock_environment) + suites = parser.parse_file() + + # Get test cases for C1050450, C1050451 (kitchen sink failing test) + all_test_cases = [] + for suite in suites: + for section in suite.testsections: + all_test_cases.extend(section.testcases) + + failing_cases = [tc for tc in all_test_cases if tc.case_id in [1050450, 1050451]] + assert len(failing_cases) == 2, "Should have 2 test cases for failing kitchen sink test" + + for case in failing_cases: + # Verify status + assert case.result.status_id == 5, f"Case {case.case_id} should be failed" + + # Verify failure information in comment + assert "Type: AssertionError" in case.result.comment + assert "Message: Payment gateway returned error" in case.result.comment + assert "Payment failed with error code 500" in case.result.comment + + # Verify attachments (should be present alongside failure info) + assert len(case.result.attachments) == 2 + assert "/failure/error_screenshot.png" in case.result.attachments + assert "/failure/stack_trace.txt" in case.result.attachments + + # Verify result fields + assert case.result.result_fields["version"] == "2.0.0" + assert case.result.result_fields["environment"] == "production" + + # Verify case fields + assert case.case_fields["custom_preconds"] == "User must be logged in" + + # Verify prepended comment + assert "Test failed during checkout" in case.result.comment + + # Verify step results + assert len(case.result.custom_step_results) == 2 + assert case.result.custom_step_results[1].content == "Checkout failed" + assert case.result.custom_step_results[1].status_id == 5 # failed + + def test_multiple_case_ids_data_independence_mutation(self, mock_environment): + """Verify that modifying one case's data doesn't affect other cases""" + mock_environment.file = "tests/test_data/XML/multiple_case_ids_with_attachments.xml" + parser = JunitParser(mock_environment) + suites = parser.parse_file() + + # Get test cases for C1050400, C1050401, C1050402 (test with attachments) + all_test_cases = [] + for suite in suites: + for section in suite.testsections: + all_test_cases.extend(section.testcases) + + test_cases = [tc for tc in all_test_cases if tc.case_id in [1050400, 1050401, 1050402]] + assert len(test_cases) == 3 + + # Store original attachment counts + original_counts = [len(tc.result.attachments) for tc in test_cases] + + # Mutate first case's attachments + test_cases[0].result.attachments.append("/mutated/new_file.txt") + + # Verify other cases are unchanged + assert ( + len(test_cases[0].result.attachments) == original_counts[0] + 1 + ), "First case should have one more attachment" + assert len(test_cases[1].result.attachments) == original_counts[1], "Second case should be unchanged" + assert len(test_cases[2].result.attachments) == original_counts[2], "Third case should be unchanged" + + # Verify the mutated attachment is only in first case + assert "/mutated/new_file.txt" in test_cases[0].result.attachments + assert "/mutated/new_file.txt" not in test_cases[1].result.attachments + assert "/mutated/new_file.txt" not in test_cases[2].result.attachments + + # Test result_fields independence + result_field_cases = [tc for tc in all_test_cases if tc.case_id in [1050410, 1050411, 1050412]] + if len(result_field_cases) == 3: + # Mutate first case's result fields + result_field_cases[0].result.result_fields["new_field"] = "mutated_value" + + # Verify other cases don't have the new field + assert "new_field" in result_field_cases[0].result.result_fields + assert "new_field" not in result_field_cases[1].result.result_fields + assert "new_field" not in result_field_cases[2].result.result_fields diff --git a/tests/test_robot_parser.py b/tests/test_robot_parser.py index 35a8594..ca73dc8 100644 --- a/tests/test_robot_parser.py +++ b/tests/test_robot_parser.py @@ -20,33 +20,32 @@ class TestRobotParser: [ # RF 5.0 format ( - MatchersParser.AUTO, - Path(__file__).parent / "test_data/XML/robotframework_simple_RF50.xml", - Path(__file__).parent / "test_data/json/robotframework_simple_RF50.json", + MatchersParser.AUTO, + Path(__file__).parent / "test_data/XML/robotframework_simple_RF50.xml", + Path(__file__).parent / "test_data/json/robotframework_simple_RF50.json", ), ( - MatchersParser.NAME, - Path(__file__).parent / "test_data/XML/robotframework_id_in_name_RF50.xml", - Path(__file__).parent / "test_data/json/robotframework_id_in_name_RF50.json", + MatchersParser.NAME, + Path(__file__).parent / "test_data/XML/robotframework_id_in_name_RF50.xml", + Path(__file__).parent / "test_data/json/robotframework_id_in_name_RF50.json", ), - # RF 7.0 format ( - MatchersParser.AUTO, - Path(__file__).parent / "test_data/XML/robotframework_simple_RF70.xml", - Path(__file__).parent / "test_data/json/robotframework_simple_RF70.json", + MatchersParser.AUTO, + Path(__file__).parent / "test_data/XML/robotframework_simple_RF70.xml", + Path(__file__).parent / "test_data/json/robotframework_simple_RF70.json", ), ( - MatchersParser.NAME, - Path(__file__).parent / "test_data/XML/robotframework_id_in_name_RF70.xml", - Path(__file__).parent / "test_data/json/robotframework_id_in_name_RF70.json", - ) + MatchersParser.NAME, + Path(__file__).parent / "test_data/XML/robotframework_id_in_name_RF70.xml", + Path(__file__).parent / "test_data/json/robotframework_id_in_name_RF70.json", + ), ], - ids=["Case Matcher Auto", "Case Matcher Name", "Case Matcher Auto", "Case Matcher Name"] + ids=["Case Matcher Auto", "Case Matcher Name", "Case Matcher Auto", "Case Matcher Name"], ) @pytest.mark.parse_robot def test_robot_xml_parser_id_matcher_name( - self, matcher: str, input_xml_path: Union[str, Path], expected_path: str, freezer + self, matcher: str, input_xml_path: Union[str, Path], expected_path: str, freezer ): freezer.move_to("2020-05-20 01:00:00") env = Environment() @@ -57,19 +56,18 @@ def test_robot_xml_parser_id_matcher_name( parsing_result_json = asdict(read_junit) file_json = open(expected_path) expected_json = json.load(file_json) - assert DeepDiff(parsing_result_json, expected_json) == {}, \ - f"Result of parsing XML is different than expected \n{DeepDiff(parsing_result_json, expected_json)}" + assert ( + DeepDiff(parsing_result_json, expected_json) == {} + ), f"Result of parsing XML is different than expected \n{DeepDiff(parsing_result_json, expected_json)}" - def __clear_unparsable_junit_elements( - self, test_rail_suite: TestRailSuite - ) -> TestRailSuite: + def __clear_unparsable_junit_elements(self, test_rail_suite: TestRailSuite) -> TestRailSuite: """helper method to delete temporary junit_case_refs attribute, which asdict() method of dataclass can't handle""" for section in test_rail_suite.testsections: for case in section.testcases: # Remove temporary junit_case_refs attribute if it exists - if hasattr(case, '_junit_case_refs'): - delattr(case, '_junit_case_refs') + if hasattr(case, "_junit_case_refs"): + delattr(case, "_junit_case_refs") return test_rail_suite @pytest.mark.parse_robot diff --git a/tests/test_version_checker.py b/tests/test_version_checker.py new file mode 100644 index 0000000..2be44a3 --- /dev/null +++ b/tests/test_version_checker.py @@ -0,0 +1,379 @@ +""" +Unit tests for version_checker module. +""" + +import json +import pytest +from datetime import datetime, timedelta +from pathlib import Path +from unittest.mock import patch, MagicMock, mock_open +from requests.exceptions import Timeout, ConnectionError, HTTPError + +from trcli import version_checker +from trcli.version_checker import ( + check_for_updates, + _query_pypi, + _get_cache, + _save_cache, + _is_cache_valid, + _compare_and_format, + _format_message, + PYPI_API_URL, + VERSION_CHECK_INTERVAL, +) + + +@pytest.fixture +def mock_cache_dir(tmp_path): + """Create a temporary cache directory for testing.""" + cache_dir = tmp_path / ".trcli" + cache_dir.mkdir(parents=True, exist_ok=True) + return cache_dir + + +@pytest.fixture +def mock_cache_file(mock_cache_dir): + """Create a temporary cache file path.""" + return mock_cache_dir / "version_cache.json" + + +@pytest.fixture(autouse=True) +def patch_cache_paths(mock_cache_dir, mock_cache_file): + """Automatically patch cache paths for all tests.""" + with patch.object(version_checker, "VERSION_CACHE_DIR", mock_cache_dir), patch.object( + version_checker, "VERSION_CACHE_FILE", mock_cache_file + ): + yield + + +class TestQueryPyPI: + """Tests for _query_pypi function.""" + + def test_query_pypi_success(self, requests_mock): + """Test successful PyPI API query.""" + mock_response = {"info": {"version": "1.14.0", "name": "trcli"}} + requests_mock.get(PYPI_API_URL, json=mock_response, status_code=200) + + result = _query_pypi() + + assert result == "1.14.0" + + def test_query_pypi_timeout(self, requests_mock): + """Test PyPI query with timeout.""" + requests_mock.get(PYPI_API_URL, exc=Timeout) + + result = _query_pypi() + + assert result is None + + def test_query_pypi_connection_error(self, requests_mock): + """Test PyPI query with connection error.""" + requests_mock.get(PYPI_API_URL, exc=ConnectionError) + + result = _query_pypi() + + assert result is None + + def test_query_pypi_http_error(self, requests_mock): + """Test PyPI query with HTTP error response.""" + requests_mock.get(PYPI_API_URL, status_code=500) + + result = _query_pypi() + + assert result is None + + def test_query_pypi_invalid_json(self, requests_mock): + """Test PyPI query with invalid JSON response.""" + requests_mock.get(PYPI_API_URL, text="not json", status_code=200) + + result = _query_pypi() + + assert result is None + + def test_query_pypi_missing_version(self, requests_mock): + """Test PyPI query when version field is missing.""" + mock_response = { + "info": { + "name": "trcli" + # version field missing + } + } + requests_mock.get(PYPI_API_URL, json=mock_response, status_code=200) + + result = _query_pypi() + + assert result is None + + def test_query_pypi_empty_info(self, requests_mock): + """Test PyPI query when info section is empty.""" + mock_response = {"info": {}} + requests_mock.get(PYPI_API_URL, json=mock_response, status_code=200) + + result = _query_pypi() + + assert result is None + + +class TestCacheManagement: + """Tests for cache read/write functions.""" + + def test_get_cache_empty(self, mock_cache_file): + """Test reading cache when file doesn't exist.""" + result = _get_cache() + + assert result == {} + + def test_get_cache_valid(self, mock_cache_file): + """Test reading valid cache file.""" + cache_data = {"last_check": "2026-02-20T10:00:00", "latest_version": "1.14.0"} + mock_cache_file.write_text(json.dumps(cache_data)) + + result = _get_cache() + + assert result == cache_data + + def test_get_cache_invalid_json(self, mock_cache_file): + """Test reading cache with invalid JSON.""" + mock_cache_file.write_text("not json") + + result = _get_cache() + + assert result == {} + + def test_save_cache_success(self, mock_cache_file): + """Test saving cache successfully.""" + cache_data = {"last_check": "2026-02-20T10:00:00", "latest_version": "1.14.0"} + + _save_cache(cache_data) + + assert mock_cache_file.exists() + saved_data = json.loads(mock_cache_file.read_text()) + assert saved_data == cache_data + + def test_save_cache_creates_directory(self, tmp_path): + """Test that save_cache creates directory if it doesn't exist.""" + new_cache_dir = tmp_path / "new_dir" / ".trcli" + new_cache_file = new_cache_dir / "version_cache.json" + + with patch.object(version_checker, "VERSION_CACHE_DIR", new_cache_dir), patch.object( + version_checker, "VERSION_CACHE_FILE", new_cache_file + ): + cache_data = {"test": "data"} + _save_cache(cache_data) + + assert new_cache_file.exists() + assert json.loads(new_cache_file.read_text()) == cache_data + + def test_save_cache_permission_error(self, mock_cache_file): + """Test save_cache handles permission errors gracefully.""" + cache_data = {"test": "data"} + + with patch("builtins.open", side_effect=OSError("Permission denied")): + # Should not raise exception + _save_cache(cache_data) + + +class TestCacheValidation: + """Tests for _is_cache_valid function.""" + + def test_is_cache_valid_no_file(self, mock_cache_file): + """Test cache validation when file doesn't exist.""" + result = _is_cache_valid() + + assert result is False + + def test_is_cache_valid_empty_cache(self, mock_cache_file): + """Test cache validation with empty cache data.""" + mock_cache_file.write_text("{}") + + result = _is_cache_valid() + + assert result is False + + def test_is_cache_valid_missing_last_check(self, mock_cache_file): + """Test cache validation when last_check is missing.""" + cache_data = {"latest_version": "1.14.0"} + mock_cache_file.write_text(json.dumps(cache_data)) + + result = _is_cache_valid() + + assert result is False + + def test_is_cache_valid_fresh_cache(self, mock_cache_file): + """Test cache validation with fresh cache (within 24 hours).""" + now = datetime.now() + cache_data = {"last_check": now.isoformat(), "latest_version": "1.14.0"} + mock_cache_file.write_text(json.dumps(cache_data)) + + result = _is_cache_valid() + + assert result is True + + def test_is_cache_valid_expired_cache(self, mock_cache_file): + """Test cache validation with expired cache (over 24 hours old).""" + old_time = datetime.now() - timedelta(seconds=VERSION_CHECK_INTERVAL + 3600) + cache_data = {"last_check": old_time.isoformat(), "latest_version": "1.14.0"} + mock_cache_file.write_text(json.dumps(cache_data)) + + result = _is_cache_valid() + + assert result is False + + def test_is_cache_valid_invalid_datetime_format(self, mock_cache_file): + """Test cache validation with invalid datetime format.""" + cache_data = {"last_check": "not a datetime", "latest_version": "1.14.0"} + mock_cache_file.write_text(json.dumps(cache_data)) + + result = _is_cache_valid() + + assert result is False + + +class TestVersionComparison: + """Tests for version comparison and formatting functions.""" + + def test_compare_and_format_newer_version(self): + """Test comparison when newer version is available.""" + result = _compare_and_format("1.13.1", "1.14.0") + + assert result is not None + assert "1.13.1" in result + assert "1.14.0" in result + assert "pip install --upgrade trcli" in result + + def test_compare_and_format_same_version(self): + """Test comparison when versions are the same.""" + result = _compare_and_format("1.13.1", "1.13.1") + + assert result is None + + def test_compare_and_format_older_version(self): + """Test comparison when current version is newer (development version).""" + result = _compare_and_format("1.14.0", "1.13.1") + + assert result is None + + def test_compare_and_format_major_version_update(self): + """Test comparison with major version update.""" + result = _compare_and_format("1.13.1", "2.0.0") + + assert result is not None + assert "1.13.1" in result + assert "2.0.0" in result + + def test_compare_and_format_invalid_version_format(self): + """Test comparison with invalid version format.""" + result = _compare_and_format("invalid", "1.14.0") + + assert result is None + + def test_format_message(self): + """Test message formatting.""" + result = _format_message("1.13.1", "1.14.0") + + assert "1.13.1" in result + assert "1.14.0" in result + assert "pip install --upgrade trcli" in result + assert "https://github.com/gurock/trcli/releases" in result + + +class TestCheckForUpdates: + """Tests for main check_for_updates function.""" + + def test_check_for_updates_with_newer_version(self, requests_mock, mock_cache_file): + """Test full flow when newer version is available.""" + mock_response = {"info": {"version": "1.14.0"}} + requests_mock.get(PYPI_API_URL, json=mock_response, status_code=200) + + result = check_for_updates("1.13.1") + + assert result is not None + assert "1.13.1" in result + assert "1.14.0" in result + assert mock_cache_file.exists() + + def test_check_for_updates_with_same_version(self, requests_mock, mock_cache_file): + """Test full flow when version is up to date.""" + mock_response = {"info": {"version": "1.13.1"}} + requests_mock.get(PYPI_API_URL, json=mock_response, status_code=200) + + result = check_for_updates("1.13.1") + + assert result is None + + def test_check_for_updates_uses_cache(self, requests_mock, mock_cache_file): + """Test that check_for_updates uses cache when valid.""" + # Setup valid cache + cache_data = {"last_check": datetime.now().isoformat(), "latest_version": "1.14.0"} + mock_cache_file.write_text(json.dumps(cache_data)) + + # Should not make API call + result = check_for_updates("1.13.1") + + assert result is not None + assert "1.14.0" in result + assert not requests_mock.called + + def test_check_for_updates_refreshes_expired_cache(self, requests_mock, mock_cache_file): + """Test that check_for_updates refreshes expired cache.""" + # Setup expired cache + old_time = datetime.now() - timedelta(seconds=VERSION_CHECK_INTERVAL + 3600) + cache_data = {"last_check": old_time.isoformat(), "latest_version": "1.14.0"} + mock_cache_file.write_text(json.dumps(cache_data)) + + # Setup API mock + mock_response = {"info": {"version": "1.15.0"}} + requests_mock.get(PYPI_API_URL, json=mock_response, status_code=200) + + result = check_for_updates("1.13.1") + + assert result is not None + assert "1.15.0" in result # Should use new version from API + assert requests_mock.called + + def test_check_for_updates_handles_api_failure_gracefully(self, requests_mock, mock_cache_file): + """Test that check_for_updates handles API failures without crashing.""" + requests_mock.get(PYPI_API_URL, exc=Timeout) + + result = check_for_updates("1.13.1") + + assert result is None # Should fail gracefully + + def test_check_for_updates_without_requests_library(self, mock_cache_file): + """Test check_for_updates when requests library is not available.""" + with patch.object(version_checker, "requests", None): + result = check_for_updates("1.13.1") + + assert result is None + + def test_check_for_updates_without_packaging_library(self, mock_cache_file): + """Test check_for_updates when packaging library is not available.""" + with patch.object(version_checker, "version", None): + result = check_for_updates("1.13.1") + + assert result is None + + def test_check_for_updates_uses_default_version(self, requests_mock, mock_cache_file): + """Test check_for_updates uses __version__ when no version provided.""" + mock_response = {"info": {"version": "1.14.0"}} + requests_mock.get(PYPI_API_URL, json=mock_response, status_code=200) + + with patch("trcli.version_checker.__version__", "1.13.1"): + result = check_for_updates() # No version parameter + + assert result is not None + assert "1.13.1" in result + + def test_check_for_updates_exception_handling(self, requests_mock, mock_cache_file): + """Test that any unexpected exception is caught and logged.""" + # Setup cache that will cause exception during processing + mock_cache_file.write_text("invalid json") + + mock_response = {"info": {"version": "1.14.0"}} + requests_mock.get(PYPI_API_URL, json=mock_response, status_code=200) + + # Should not raise exception + result = check_for_updates("1.13.1") + + # With invalid cache, should fall back to API call + assert result is not None diff --git a/tests_e2e/reports_junit/multiple_case_ids.xml b/tests_e2e/reports_junit/multiple_case_ids.xml new file mode 100644 index 0000000..66fa83e --- /dev/null +++ b/tests_e2e/reports_junit/multiple_case_ids.xml @@ -0,0 +1,44 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + +Expected: All scenarios pass +Actual: Scenario failed with assertion error +Stack trace: + at com.example.FailedTest.testScenario(FailedTest.java:42) + at com.example.TestRunner.run(TestRunner.java:15) + + + + + + + + + + + + diff --git a/tests_e2e/test_end2end.py b/tests_e2e/test_end2end.py index 5966926..6afe43e 100644 --- a/tests_e2e/test_end2end.py +++ b/tests_e2e/test_end2end.py @@ -339,6 +339,49 @@ def test_cli_attachments(self): ], ) + def test_cli_multiple_case_ids(self): + """Test multiple case IDs feature - one test updates multiple TestRail cases + + This test verifies that when a single JUnit test specifies multiple case IDs + using comma-separated values in the test_id property, TRCLI correctly: + 1. Creates separate results for each case ID + 2. Duplicates attachments for each case ID + 3. Applies custom fields to each case ID + 4. Uploads all results to TestRail successfully + + Test data breakdown: + - Test 1: Single case ID (C505288) - baseline + - Test 2: Multiple IDs (C505289, C505290, C505291) with attachments - passing + - Test 3: Multiple IDs (C505292, C505293) with attachments - failing + + Expected results: 6 total case results (1 + 3 + 2) + Expected attachments: 5 total uploads (0 + 3 + 2) + """ + output = _run_cmd( + f""" +trcli -y \\ + -h {self.TR_INSTANCE} \\ + --project "SA - (DO NOT DELETE) TRCLI-E2E-Tests" \\ + parse_junit \\ + --title "[CLI-E2E-Tests] Multiple Case IDs" \\ + --case-matcher property \\ + -f "reports_junit/multiple_case_ids.xml" + """ + ) + _assert_contains( + output, + [ + # Parser processes 6 testcases from XML + "Processed 6 test cases in section [MULTIPLE-CASE-IDS]", + # Creates test run in TestRail + f"Creating test run. Test run: {self.TR_INSTANCE}index.php?/runs/view", + # Uploads attachments: Test 2 (1 × 3 cases) + Test 3 (1 × 2 cases) = 5 + "Uploading 5 attachments for 5 test results", + # Submits results: 1 (single) + 3 (test 2) + 2 (test 3) = 6 total + "Submitted 6 test results in", + ], + ) + def test_cli_multisuite_with_suite_id(self): output = _run_cmd( f""" diff --git a/trcli/__init__.py b/trcli/__init__.py index 8d7257c..f77d042 100644 --- a/trcli/__init__.py +++ b/trcli/__init__.py @@ -1 +1 @@ -__version__ = "1.13.2" +__version__ = "1.13.3" diff --git a/trcli/api/case_handler.py b/trcli/api/case_handler.py index b8aeaee..d8902a4 100644 --- a/trcli/api/case_handler.py +++ b/trcli/api/case_handler.py @@ -114,6 +114,14 @@ def _add_case_and_update_data(self, case: TestRailCase) -> APIClientResult: case.case_id = response.response_text["id"] case.result.case_id = response.response_text["id"] case.section_id = response.response_text["section_id"] + + # Propagate case_id to all duplicate cases + if hasattr(case, "_duplicates"): + for duplicate_case in case._duplicates: + duplicate_case.case_id = response.response_text["id"] + duplicate_case.result.case_id = response.response_text["id"] + duplicate_case.section_id = response.response_text["section_id"] + return response def update_existing_case_references( diff --git a/trcli/cli.py b/trcli/cli.py index 58d84a3..716ed8d 100755 --- a/trcli/cli.py +++ b/trcli/cli.py @@ -24,6 +24,10 @@ from trcli.logging import get_logger from trcli.logging.config import LoggingConfig +# Import version checker +from trcli import __version__ +from trcli.version_checker import check_for_updates + CONTEXT_SETTINGS = dict(auto_envvar_prefix="TR_CLI") trcli_folder = Path(__file__).parent @@ -293,6 +297,15 @@ def __init__(self, *args, **kwargs): # Use invoke_without_command=True to be able to print # short tool description when starting without parameters print(TOOL_VERSION) + + # Check for updates (non-blocking) + try: + update_message = check_for_updates(__version__) + if update_message: + click.secho(update_message, fg="yellow", err=True) + except Exception: + pass + click.MultiCommand.__init__(self, invoke_without_command=True, *args, **kwargs) def list_commands(self, context: click.Context): diff --git a/trcli/commands/cmd_parse_cucumber.py b/trcli/commands/cmd_parse_cucumber.py index 3634bcd..ba1e6a7 100644 --- a/trcli/commands/cmd_parse_cucumber.py +++ b/trcli/commands/cmd_parse_cucumber.py @@ -132,7 +132,7 @@ def cli(environment: Environment, context: click.Context, *args, **kwargs): environment.log(f"\n=== Auto-Creating {len(features_to_create)} Missing BDD Test Case(s) ===") # Load Cucumber JSON to access raw feature data - with open(environment.file, "r", encoding="utf-8") as f: + with open(parser.filepath, "r", encoding="utf-8") as f: cucumber_data = json.load(f) # Get BDD template ID @@ -152,6 +152,11 @@ def cli(environment: Environment, context: click.Context, *args, **kwargs): feature_name = feature.get("name", "Untitled Feature") normalized_name = parser._normalize_title(feature_name) + # Skip if already created (handles duplicate features from merged files) + if normalized_name in created_case_ids: + environment.vlog(f"Feature '{feature_name}' already created, skipping duplicate") + continue + # Check if this feature needs creation needs_creation = any( parser._normalize_title(item["section"].name) == normalized_name for item in features_to_create @@ -263,8 +268,8 @@ def cli(environment: Environment, context: click.Context, *args, **kwargs): else: environment.log("Results processing completed") - except FileNotFoundError: - environment.elog(f"Error: Cucumber JSON file not found: {environment.file}") + except FileNotFoundError as e: + environment.elog(str(e)) exit(1) except json.JSONDecodeError as e: environment.elog(f"Error: Invalid JSON format in file: {environment.file}") diff --git a/trcli/commands/cmd_parse_junit.py b/trcli/commands/cmd_parse_junit.py index 40c3b21..2c95beb 100644 --- a/trcli/commands/cmd_parse_junit.py +++ b/trcli/commands/cmd_parse_junit.py @@ -89,8 +89,8 @@ def cli(environment: Environment, context: click.Context, *args, **kwargs): # Exit with error if there were case update failures (after reporting) if case_update_results.get("failed_cases"): exit(1) - except FileNotFoundError: - environment.elog(FAULT_MAPPING["missing_file"]) + except FileNotFoundError as e: + environment.elog(str(e)) exit(1) except (JUnitXmlError, ParseError): environment.elog(FAULT_MAPPING["invalid_file"]) diff --git a/trcli/commands/cmd_parse_robot.py b/trcli/commands/cmd_parse_robot.py index e679d6c..a09ac21 100644 --- a/trcli/commands/cmd_parse_robot.py +++ b/trcli/commands/cmd_parse_robot.py @@ -27,8 +27,8 @@ def cli(environment: Environment, context: click.Context, *args, **kwargs): for suite in parsed_suites: result_uploader = ResultsUploader(environment=environment, suite=suite) result_uploader.upload_results() - except FileNotFoundError: - environment.elog(FAULT_MAPPING["missing_file"]) + except FileNotFoundError as e: + environment.elog(str(e)) exit(1) except ParseError: environment.elog(FAULT_MAPPING["invalid_file"]) @@ -42,4 +42,3 @@ def cli(environment: Environment, context: click.Context, *args, **kwargs): ) ) exit(1) - diff --git a/trcli/commands/cmd_update.py b/trcli/commands/cmd_update.py new file mode 100644 index 0000000..3c8cb66 --- /dev/null +++ b/trcli/commands/cmd_update.py @@ -0,0 +1,141 @@ +""" +Update command for TRCLI. + +Provides a convenient way to update TRCLI to the latest version from PyPI. +""" + +import sys +import subprocess +import click + +from trcli.cli import CONTEXT_SETTINGS +from trcli.version_checker import _query_pypi, _compare_and_format, _save_cache +from trcli import __version__ +from datetime import datetime + + +@click.command(context_settings=CONTEXT_SETTINGS) +@click.option( + "--check-only", + is_flag=True, + help="Only check for updates without installing.", +) +@click.option( + "--force", + is_flag=True, + help="Force reinstall even if already on latest version.", +) +def cli(check_only: bool, force: bool): + """Update TRCLI to the latest version from PyPI. + + This command checks PyPI for the latest version and updates TRCLI + using pip. It will show what version will be installed before proceeding. + + """ + click.echo(f"Current version: {__version__}") + click.echo() + + # Query PyPI for latest version + click.echo("Checking PyPI for latest version...") + latest_version = _query_pypi() + + if latest_version is None: + click.secho("✗ Failed to query PyPI. Check your network connection.", fg="red", err=True) + sys.exit(1) + + # Update cache with the latest version information + _save_cache({"last_check": datetime.now().isoformat(), "latest_version": latest_version}) + + click.echo(f"Latest version on PyPI: {latest_version}") + click.echo() + + # Check if update is available + update_message = _compare_and_format(__version__, latest_version) + + if update_message is None and not force: + click.secho(f"✓ You are already on the latest version ({__version__})!", fg="green") + click.echo() + click.echo("Use --force to reinstall the current version.") + sys.exit(0) + + # If check-only, just display the message and exit + if check_only: + if update_message: + click.echo(update_message) + else: + click.secho(f"✓ You are already on the latest version ({__version__})!", fg="green") + sys.exit(0) + + # Show what will be updated + if force: + click.secho(f"⚠️ Forcing reinstall of version {__version__}", fg="yellow") + else: + click.secho(f"→ Updating from {__version__} to {latest_version}", fg="blue") + + click.echo() + + # Confirm with user + if not click.confirm("Do you want to proceed with the update?"): + click.echo("Update cancelled.") + sys.exit(0) + + click.echo() + click.echo("Starting update...") + click.echo("=" * 60) + click.echo() + + # Prepare pip command + pip_command = [sys.executable, "-m", "pip", "install", "--upgrade", "trcli"] + + if force: + pip_command.append("--force-reinstall") + + # Run pip install + try: + result = subprocess.run( + pip_command, + capture_output=False, # Show pip output directly + text=True, + check=False, # Don't raise exception, we'll check returncode + ) + + click.echo() + click.echo("=" * 60) + click.echo() + + if result.returncode == 0: + # Success + click.secho("✓ Update completed successfully!", fg="green", bold=True) + click.echo() + click.echo("Run 'trcli' to verify the new version.") + + # Check if we need to inform about restart + if sys.prefix != sys.base_prefix: # In virtual environment + click.echo() + click.secho("Note: You may need to restart your virtual environment.", fg="yellow") + + else: + # Failed + click.secho("✗ Update failed!", fg="red", bold=True, err=True) + click.echo() + click.echo("Common issues and solutions:", err=True) + click.echo(" • Permission denied: Try using --user flag", err=True) + click.echo(" pip install --user --upgrade trcli", err=True) + click.echo(" • Already satisfied: You may already have the latest version", err=True) + click.echo(" • Network issues: Check your internet connection", err=True) + sys.exit(result.returncode) + + except FileNotFoundError: + click.secho("✗ Error: pip not found!", fg="red", bold=True, err=True) + click.echo() + click.echo("Please ensure pip is installed and available in your PATH.", err=True) + sys.exit(1) + + except KeyboardInterrupt: + click.echo() + click.secho("✗ Update interrupted by user.", fg="yellow", err=True) + sys.exit(130) + + except Exception as e: + click.secho(f"✗ Unexpected error: {e}", fg="red", bold=True, err=True) + sys.exit(1) diff --git a/trcli/data_providers/api_data_provider.py b/trcli/data_providers/api_data_provider.py index e3aacd7..9570c13 100644 --- a/trcli/data_providers/api_data_provider.py +++ b/trcli/data_providers/api_data_provider.py @@ -18,7 +18,7 @@ def __init__( case_fields: dict = None, run_description: str = None, result_fields: dict = None, - parent_section_id: int = None + parent_section_id: int = None, ): self.suites_input = suites_input self.case_fields = case_fields @@ -42,13 +42,32 @@ def add_sections_data(self, return_all_items=False) -> list: ] def add_cases(self, return_all_items=False) -> list: - """Return list of bodies for adding test cases.""" + """Return list of bodies for adding test cases. + + Deduplicates by automation_id to prevent creating duplicate cases when + merged files contain the same test multiple times (glob pattern support). + Duplicates are linked so they receive the same case_id after creation. + """ testcases = [sections.testcases for sections in self.suites_input.testsections] bodies = [] + seen_automation_ids = {} + for sublist in testcases: for case in sublist: if case.case_id is None or return_all_items: case.add_global_case_fields(self.case_fields) + + # Deduplicate by automation_id to avoid creating duplicate cases + if case.custom_automation_id: + if case.custom_automation_id in seen_automation_ids: + master_case = seen_automation_ids[case.custom_automation_id] + if not hasattr(master_case, "_duplicates"): + master_case._duplicates = [] + master_case._duplicates.append(case) + continue + else: + seen_automation_ids[case.custom_automation_id] = case + bodies.append(case) return bodies @@ -64,23 +83,20 @@ def existing_cases(self): return bodies def add_run( - self, - run_name: Optional[str], - case_ids=None, - start_date=None, - end_date=None, - milestone_id=None, - assigned_to_id=None, - include_all=None, - refs=None, + self, + run_name: Optional[str], + case_ids=None, + start_date=None, + end_date=None, + milestone_id=None, + assigned_to_id=None, + include_all=None, + refs=None, ): """Return body for adding or updating a run.""" if case_ids is None: case_ids = [ - int(case) - for section in self.suites_input.testsections - for case in section.testcases - if int(case) > 0 + int(case) for section in self.suites_input.testsections for case in section.testcases if int(case) > 0 ] properties = [ str(prop) @@ -90,11 +106,7 @@ def add_run( ] if self.run_description: properties.insert(0, f"{self.run_description}\n") - body = { - "suite_id": self.suites_input.suite_id, - "description": "\n".join(properties), - "case_ids": case_ids - } + body = {"suite_id": self.suites_input.suite_id, "description": "\n".join(properties), "case_ids": case_ids} if isinstance(start_date, list) and start_date is not None: try: dt = datetime(start_date[2], start_date[0], start_date[1], tzinfo=timezone.utc) @@ -132,17 +144,17 @@ def add_results_for_cases(self, bulk_size, user_ids=None): for case in sublist: if case.case_id is not None: case.result.add_global_result_fields(self.result_fields) - + # Count failed tests if case.result.status_id == 5: # status_id 5 = Failed total_failed_count += 1 - + # Assign failed tests to users in round-robin fashion if user_ids provided if user_ids: case.result.assignedto_id = user_ids[user_index % len(user_ids)] user_index += 1 assigned_count += 1 - + bodies.append(case.result.to_dict()) # Store counts for logging (we'll access this from the api_request_handler) @@ -205,11 +217,7 @@ def __update_section_data(self, section_data: List[Dict]): """ for section_updater in section_data: matched_section = next( - ( - section - for section in self.suites_input.testsections - if section["name"] == section_updater["name"] - ), + (section for section in self.suites_input.testsections if section["name"] == section_updater["name"]), None, ) if matched_section is not None: @@ -235,33 +243,27 @@ def __update_case_data(self, case_data: List[Dict]): """ testcases = [sections.testcases for sections in self.suites_input.testsections] for case_updater in case_data: - matched_case = next( - ( - case - for sublist in testcases - for case in sublist - if case.custom_automation_id == case_updater[OLD_SYSTEM_NAME_AUTOMATION_ID] - ), - None, - ) - if matched_case is None: - matched_case = next( - ( - case - for sublist in testcases - for case in sublist - if hasattr(case, UPDATED_SYSTEM_NAME_AUTOMATION_ID) - and case.custom_case_automation_id == case_updater.get(UPDATED_SYSTEM_NAME_AUTOMATION_ID) - ), - None, - ) - if matched_case is not None: - matched_case.case_id = case_updater["case_id"] - matched_case.result.case_id = case_updater["case_id"] - matched_case.section_id = case_updater["section_id"] + # Update ALL cases with matching automation_id (not just first match) + # This is critical for glob pattern support where multiple files contain the same test + automation_id = case_updater.get(OLD_SYSTEM_NAME_AUTOMATION_ID) + updated_automation_id = case_updater.get(UPDATED_SYSTEM_NAME_AUTOMATION_ID) + + for sublist in testcases: + for case in sublist: + # Check both old and new automation_id field names + matches = False + if automation_id and case.custom_automation_id == automation_id: + matches = True + elif updated_automation_id and hasattr(case, UPDATED_SYSTEM_NAME_AUTOMATION_ID): + if case.custom_case_automation_id == updated_automation_id: + matches = True + + if matches: + # Update this case (may be one of many duplicates) + case.case_id = case_updater["case_id"] + case.result.case_id = case_updater["case_id"] + case.section_id = case_updater["section_id"] @staticmethod def divide_list_into_bulks(input_list: List, bulk_size: int) -> List: - return [ - input_list[i : i + bulk_size] for i in range(0, len(input_list), bulk_size) - ] + return [input_list[i : i + bulk_size] for i in range(0, len(input_list), bulk_size)] diff --git a/trcli/readers/cucumber_json.py b/trcli/readers/cucumber_json.py index d018aa2..93389c0 100644 --- a/trcli/readers/cucumber_json.py +++ b/trcli/readers/cucumber_json.py @@ -1,6 +1,7 @@ import json +import glob from pathlib import Path -from beartype.typing import List, Dict, Any, Optional, Tuple +from beartype.typing import List, Dict, Any, Optional, Tuple, Union from trcli.cli import Environment from trcli.data_classes.data_parsers import MatchersParser, TestRailCaseFieldsOptimizer @@ -23,6 +24,57 @@ def __init__(self, environment: Environment): self._bdd_case_cache = None # Cache for BDD cases (populated on first use) self._api_handler = None # Will be set when BDD matching mode is needed + @staticmethod + def check_file(filepath: Union[str, Path]) -> Path: + """ + Check and process file path, supporting glob patterns for multiple files. + + If glob pattern matches multiple files, they are merged into a single Cucumber JSON report. + + Args: + filepath: File path or glob pattern (e.g., "reports/*.json", "cucumber.json") + + Returns: + Path to the file (or merged file if multiple matches) + + Raises: + FileNotFoundError: If no files match the pattern + ValueError: If JSON file is not valid Cucumber format (array of features) + """ + filepath = Path(filepath) + files = glob.glob(str(filepath)) + + 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]) + + # Multiple files - merge them into single Cucumber JSON report + merged_features = [] + + for file in files: + with open(file, "r", encoding="utf-8") as f: + features = json.load(f) + + # Validate Cucumber JSON format (must be array of features) + if not isinstance(features, list): + raise ValueError( + f"Invalid Cucumber JSON format in {file}: " + f"Expected array of features, got {type(features).__name__}" + ) + + # Merge features from this file + merged_features.extend(features) + + # Write merged report + merged_report_path = Path().cwd().joinpath("Merged-Cucumber-report.json") + + with open(merged_report_path, "w", encoding="utf-8") as f: + json.dump(merged_features, f, indent=2, ensure_ascii=False) + + return merged_report_path + def parse_file( self, bdd_matching_mode: bool = False, diff --git a/trcli/readers/junit_xml.py b/trcli/readers/junit_xml.py index 6014be5..3cc6801 100644 --- a/trcli/readers/junit_xml.py +++ b/trcli/readers/junit_xml.py @@ -67,7 +67,7 @@ def check_file(filepath: Union[str, Path]) -> Path: filepath = Path(filepath) files = glob.glob(str(filepath)) if not files: - raise FileNotFoundError("File not found.") + raise FileNotFoundError(f"File not found: {filepath}") elif len(files) == 1: return Path().cwd().joinpath(files[0]) sub_suites = [] diff --git a/trcli/readers/robot_xml.py b/trcli/readers/robot_xml.py index d82fdcf..0ef44e2 100644 --- a/trcli/readers/robot_xml.py +++ b/trcli/readers/robot_xml.py @@ -9,7 +9,8 @@ TestRailCase, TestRailSuite, TestRailSection, - TestRailResult, TestRailSeparatedStep, + TestRailResult, + TestRailSeparatedStep, ) from trcli.readers.file_parser import FileParser @@ -60,8 +61,10 @@ def _find_suites(self, suite_element, sections_list: List, namespace=""): if documentation is not None: lines = [line.strip() for line in documentation.text.splitlines()] for line in lines: - if line.lower().startswith("- testrail_case_id:") \ - and self.case_matcher == MatchersParser.PROPERTY: + if ( + line.lower().startswith("- testrail_case_id:") + and self.case_matcher == MatchersParser.PROPERTY + ): case_id = int(self._remove_tr_prefix(line, "- testrail_case_id:").lower().replace("c", "")) if line.lower().startswith("- testrail_attachment:"): attachments.append(self._remove_tr_prefix(line, "- testrail_attachment:")) @@ -72,20 +75,17 @@ def _find_suites(self, suite_element, sections_list: List, namespace=""): if line.lower().startswith("- testrail_case_field"): case_fields.append(self._remove_tr_prefix(line, "- testrail_case_field:")) status = test.find("status") - status_dict = { - "pass": 1, - "not run": 3, - "skip": 4, - "fail": 5 - } + status_dict = {"pass": 1, "not run": 3, "skip": 4, "fail": 5} status_id = status_dict[status.get("status").lower()] elapsed_time = None # if status contains "elapsed" then obtain it, otherwise calculate it from starttime and endtime if "elapsed" in status.attrib: - elapsed_time = self._parse_rf70_elapsed_time(status.get("elapsed")) + elapsed_time = self._parse_rf70_elapsed_time(status.get("elapsed")) else: - elapsed_time = self._parse_rf50_time(status.get("endtime")) - self._parse_rf50_time(status.get("starttime")) + elapsed_time = self._parse_rf50_time(status.get("endtime")) - self._parse_rf50_time( + status.get("starttime") + ) error_msg = status.text keywords = test.findall("kw") @@ -111,18 +111,22 @@ def _find_suites(self, suite_element, sections_list: List, namespace=""): comment=error_msg, attachments=attachments, result_fields=result_fields_dict, - custom_step_results=step_keywords + custom_step_results=step_keywords, ) for comment in reversed(comments): result.prepend_comment(comment) tr_test = TestRailCase( - title=TestRailCaseFieldsOptimizer.extract_last_words(case_name, TestRailCaseFieldsOptimizer.MAX_TESTCASE_TITLE_LENGTH), + title=TestRailCaseFieldsOptimizer.extract_last_words( + case_name, TestRailCaseFieldsOptimizer.MAX_TESTCASE_TITLE_LENGTH + ), case_id=case_id, result=result, - custom_automation_id=f"{namespace}.{case_name}" - if not hasattr(test, "custom_case_automation_id") - else test.custom_case_automation_id, - case_fields=case_fields_dict + custom_automation_id=( + f"{namespace}.{case_name}" + if not hasattr(test, "custom_case_automation_id") + else test.custom_case_automation_id + ), + case_fields=case_fields_dict, ) section.testcases.append(tr_test) @@ -132,13 +136,13 @@ def _find_suites(self, suite_element, sections_list: List, namespace=""): @staticmethod def _parse_rf50_time(time_str: str) -> datetime: # "20230712 22:32:12.951" - return datetime.strptime(time_str, '%Y%m%d %H:%M:%S.%f') - + return datetime.strptime(time_str, "%Y%m%d %H:%M:%S.%f") + @staticmethod def _parse_rf70_time(time_str: str) -> datetime: # "2023-07-12T22:32:12.951000" - return datetime.strptime(time_str, '%Y-%m-%dT%H:%M:%S.%f') - + return datetime.strptime(time_str, "%Y-%m-%dT%H:%M:%S.%f") + @staticmethod def _parse_rf70_elapsed_time(timedelta_str: str) -> timedelta: # "0.001000" diff --git a/trcli/version_checker.py b/trcli/version_checker.py new file mode 100644 index 0000000..f29131c --- /dev/null +++ b/trcli/version_checker.py @@ -0,0 +1,234 @@ +""" +Version checker module for TRCLI. + +Checks PyPI for the latest version of trcli and notifies users if an update is available. +Uses a cache file to avoid excessive API calls (24-hour cache TTL). +""" + +import json +import logging +from datetime import datetime, timedelta +from pathlib import Path +from typing import Optional, Dict, Any + +try: + import requests +except ImportError: + requests = None + +try: + from packaging import version +except ImportError: + version = None + +from trcli import __version__ + +# Constants +PYPI_API_URL = "https://pypi.org/pypi/trcli/json" +VERSION_CHECK_INTERVAL = 86400 # 24 hours in seconds +VERSION_CACHE_DIR = Path.home() / ".trcli" +VERSION_CACHE_FILE = VERSION_CACHE_DIR / "version_cache.json" +REQUEST_TIMEOUT = 2 # seconds + +logger = logging.getLogger(__name__) + + +def check_for_updates(current_version: Optional[str] = None) -> Optional[str]: + """ + Check if a newer version of TRCLI is available on PyPI. + + Args: + current_version: Current version string. Defaults to trcli.__version__ + + Returns: + Formatted update message if newer version available, None otherwise + + Note: + This function never raises exceptions - all errors are caught and logged. + """ + if requests is None or version is None: + logger.debug("Required dependencies (requests, packaging) not available for version check") + return None + + if current_version is None: + current_version = __version__ + + try: + # Check if we need to query PyPI (cache expired or doesn't exist) + if _is_cache_valid(): + cache_data = _get_cache() + latest_version = cache_data.get("latest_version") + if latest_version: + return _compare_and_format(current_version, latest_version) + + # Cache expired or invalid, query PyPI + latest_version = _query_pypi() + if latest_version: + # Save to cache + _save_cache({"last_check": datetime.now().isoformat(), "latest_version": latest_version}) + return _compare_and_format(current_version, latest_version) + + except Exception as e: + logger.debug(f"Version check failed: {e}") + + return None + + +def _query_pypi() -> Optional[str]: + """ + Query PyPI API for the latest version of trcli. + + Returns: + Latest version string from PyPI, or None if query fails + """ + try: + logger.debug(f"Querying PyPI API: {PYPI_API_URL}") + response = requests.get(PYPI_API_URL, timeout=REQUEST_TIMEOUT) + response.raise_for_status() + + data = response.json() + latest_version = data.get("info", {}).get("version") + + if latest_version: + logger.debug(f"Latest version from PyPI: {latest_version}") + return latest_version + else: + logger.debug("Could not extract version from PyPI response") + return None + + except requests.exceptions.Timeout: + logger.debug("PyPI API request timed out") + return None + except requests.exceptions.ConnectionError: + logger.debug("Could not connect to PyPI (network issue)") + return None + except requests.exceptions.HTTPError as e: + logger.debug(f"PyPI API returned error: {e}") + return None + except json.JSONDecodeError: + logger.debug("Invalid JSON response from PyPI") + return None + except Exception as e: + logger.debug(f"Unexpected error querying PyPI: {e}") + return None + + +def _get_cache() -> Dict[str, Any]: + """ + Read version cache from disk. + + Returns: + Cache data dictionary, or empty dict if cache doesn't exist or is invalid + """ + try: + if VERSION_CACHE_FILE.exists(): + with open(VERSION_CACHE_FILE, "r") as f: + return json.load(f) + except (json.JSONDecodeError, IOError, OSError) as e: + logger.debug(f"Failed to read version cache: {e}") + + return {} + + +def _save_cache(data: Dict[str, Any]) -> None: + """ + Save version cache to disk. + + Args: + data: Cache data dictionary to save + + Note: + Creates cache directory if it doesn't exist. Failures are logged but not raised. + """ + try: + # Create cache directory if it doesn't exist + VERSION_CACHE_DIR.mkdir(parents=True, exist_ok=True) + + with open(VERSION_CACHE_FILE, "w") as f: + json.dump(data, f) + + logger.debug(f"Version cache saved to {VERSION_CACHE_FILE}") + + except (IOError, OSError) as e: + logger.debug(f"Failed to save version cache: {e}") + + +def _is_cache_valid() -> bool: + """ + Check if the version cache is valid (exists and not expired). + + Returns: + True if cache is valid and fresh, False otherwise + """ + try: + if not VERSION_CACHE_FILE.exists(): + logger.debug("Version cache does not exist") + return False + + cache_data = _get_cache() + if not cache_data or "last_check" not in cache_data: + logger.debug("Version cache is empty or invalid") + return False + + last_check_str = cache_data["last_check"] + last_check = datetime.fromisoformat(last_check_str) + + # Check if cache is still fresh (within 24 hours) + cache_age = datetime.now() - last_check + is_valid = cache_age < timedelta(seconds=VERSION_CHECK_INTERVAL) + + if is_valid: + logger.debug(f"Version cache is valid (age: {cache_age})") + else: + logger.debug(f"Version cache expired (age: {cache_age})") + + return is_valid + + except (ValueError, KeyError) as e: + logger.debug(f"Failed to validate cache: {e}") + return False + + +def _compare_and_format(current: str, latest: str) -> Optional[str]: + """ + Compare current and latest versions, and format update message if newer version available. + + Args: + current: Current version string + latest: Latest version string from PyPI + + Returns: + Formatted update message if latest > current, None otherwise + """ + try: + current_ver = version.parse(current) + latest_ver = version.parse(latest) + + if latest_ver > current_ver: + return _format_message(current, latest) + else: + logger.debug(f"Current version {current} is up to date (latest: {latest})") + return None + + except Exception as e: + logger.debug(f"Failed to compare versions: {e}") + return None + + +def _format_message(current: str, latest: str) -> str: + """ + Format the update notification message. + + Args: + current: Current version string + latest: Latest version string + + Returns: + Formatted multi-line update message + """ + return ( + f"\n A new version of TestRail CLI is available!\n" + f" Current: {current} | Latest: {latest}\n" + f" Update with: pip install --upgrade trcli\n" + f" Release notes: https://github.com/gurock/trcli/releases\n" + )