Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 19 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -196,7 +196,7 @@ For further detail, please refer to the

### Using Glob Patterns for Multiple Files

TRCLI supports glob patterns to process multiple report files in a single command. This feature is available for **JUnit XML** and **Cucumber JSON** parsers.
TRCLI supports glob patterns to process multiple report files in a single command. This feature is available for **JUnit XML**, **Robot Framework**, and **Cucumber JSON** parsers.

#### Important: Shell Quoting Requirement

Expand Down Expand Up @@ -239,6 +239,7 @@ When a glob pattern matches **multiple files**, TRCLI automatically:
3. **Merges test results** into a single combined report
4. **Writes merged file** to current directory:
- JUnit: `Merged-JUnit-report.xml`
- Robot Framework: `Merged-Robot-report.xml`
- Cucumber: `merged_cucumber.json`
5. **Processes the merged file** as a single test run upload

Expand All @@ -263,6 +264,23 @@ trcli parse_junit \
--case-matcher auto
```

**Robot Framework - Multiple output files:**
```bash
# Merge multiple Robot Framework test runs
trcli -y \
-h https://example.testrail.com \
--project "My Project" \
parse_robot \
-f "reports/robot-*.xml" \
--title "Merged Robot Tests"

# Recursive search for all Robot outputs
trcli parse_robot \
-f "test-results/**/output.xml" \
--title "All Robot Results" \
--case-matcher property
```

**Cucumber JSON - Multiple test runs:**
```bash
# Merge multiple Cucumber JSON reports
Expand Down
31 changes: 31 additions & 0 deletions tests/test_glob_integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,37 @@ def test_glob_junit_multiple_results_scenario_2(self):
if merged_file.exists():
merged_file.unlink()

@pytest.mark.parse_robot
def test_glob_robot_duplicate_automation_ids(self):
"""Test Robot Framework glob pattern with duplicate automation_ids."""
env = Environment()
env.case_matcher = MatchersParser.AUTO
env.file = Path(__file__).parent / "test_data/XML/testglob_robot/*.xml"

# Check if test files exist
if not list(Path(__file__).parent.glob("test_data/XML/testglob_robot/*.xml")):
pytest.skip("Robot test data not available")

parser = RobotParser(env)
parsed_suites = parser.parse_file()
suite = parsed_suites[0]

# Similar verification as JUnit tests
data_provider = ApiDataProvider(suite)
cases_to_add = data_provider.add_cases()

# Verify deduplication occurred if there were duplicates
total_cases = sum(len(section.testcases) for section in suite.testsections)
automation_ids = [c.custom_automation_id for c in cases_to_add if c.custom_automation_id]

# Cases to add should have unique automation_ids
assert len(automation_ids) == len(set(automation_ids)), "Cases to add should have unique automation_ids"

# Clean up merged file
merged_file = Path.cwd() / "Merged-Robot-report.xml"
if merged_file.exists():
merged_file.unlink()

@pytest.mark.parse_cucumber
def test_cucumber_glob_filepath_not_pattern(self):
"""Test Scenario 3: Cucumber glob pattern uses correct filepath (not pattern string).
Expand Down
108 changes: 108 additions & 0 deletions tests/test_robot_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,3 +76,111 @@ def test_robot_xml_parser_file_not_found(self):
env = Environment()
env.file = Path(__file__).parent / "not_found.xml"
RobotParser(env)

@pytest.mark.parse_robot
def test_robot_xml_parser_glob_pattern_single_file(self):
"""Test glob pattern that matches single file"""
env = Environment()
env.case_matcher = MatchersParser.AUTO
# Use glob pattern that matches only one file
env.file = Path(__file__).parent / "test_data/XML/robotframework_simple_RF50.xml"

# This should work just like a regular file path
file_reader = RobotParser(env)
result = file_reader.parse_file()

assert len(result) == 1
assert isinstance(result[0], TestRailSuite)
# Verify it has test sections and cases
assert len(result[0].testsections) > 0

@pytest.mark.parse_robot
def test_robot_xml_parser_glob_pattern_multiple_files(self):
"""Test glob pattern that matches multiple files and merges them"""
env = Environment()
env.case_matcher = MatchersParser.AUTO
# Use glob pattern that matches multiple Robot XML files
env.file = Path(__file__).parent / "test_data/XML/testglob_robot/*.xml"

file_reader = RobotParser(env)
result = file_reader.parse_file()

# Should return a merged result
assert len(result) == 1
assert isinstance(result[0], TestRailSuite)

# Verify merged file was created
merged_file = Path.cwd() / "Merged-Robot-report.xml"
assert merged_file.exists(), "Merged Robot report should be created"

# Verify the merged result contains test cases from both files
total_cases = sum(len(section.testcases) for section in result[0].testsections)
assert total_cases > 0, "Merged result should contain test cases"

# Clean up merged file
if merged_file.exists():
merged_file.unlink()

@pytest.mark.parse_robot
def test_robot_xml_parser_glob_pattern_no_matches(self):
"""Test glob pattern that matches no files"""
with pytest.raises(FileNotFoundError):
env = Environment()
env.case_matcher = MatchersParser.AUTO
# Use glob pattern that matches no files
env.file = Path(__file__).parent / "test_data/XML/nonexistent_*.xml"
RobotParser(env)

@pytest.mark.parse_robot
def test_robot_check_file_glob_returns_path(self):
"""Test that check_file method returns valid Path for glob pattern"""
# Test single file match
single_file_glob = Path(__file__).parent / "test_data/XML/robotframework_simple_RF50.xml"
result = RobotParser.check_file(single_file_glob)
assert isinstance(result, Path)
assert result.exists()

# Test multiple file match (returns merged file path)
multi_file_glob = Path(__file__).parent / "test_data/XML/testglob_robot/*.xml"
result = RobotParser.check_file(multi_file_glob)
assert isinstance(result, Path)
assert result.name == "Merged-Robot-report.xml"
assert result.exists()

# Clean up
if result.exists() and result.name == "Merged-Robot-report.xml":
result.unlink()

@pytest.mark.parse_robot
def test_robot_xml_parser_glob_merges_duplicate_sections(self):
"""Test that glob pattern merging handles duplicate section names correctly.

When multiple Robot XML files have the same suite structure, sections with
the same name should be merged into one section with all test cases combined.
This prevents the "Section duplicates detected" error.
"""
env = Environment()
env.case_matcher = MatchersParser.AUTO
env.file = Path(__file__).parent / "test_data/XML/testglob_robot/*.xml"

file_reader = RobotParser(env)
result = file_reader.parse_file()

assert len(result) == 1
suite = result[0]

# Verify no duplicate section names
section_names = [section.name for section in suite.testsections]
unique_section_names = set(section_names)

assert len(section_names) == len(unique_section_names), f"Duplicate section names detected: {section_names}"

# Verify sections have combined test cases from both files
# Both robot-1.xml and robot-2.xml have same structure, so sections should have tests from both
total_cases = sum(len(section.testcases) for section in suite.testsections)
assert total_cases > 4, "Sections should contain test cases from both merged files"

# Clean up merged file
merged_file = Path.cwd() / "Merged-Robot-report.xml"
if merged_file.exists():
merged_file.unlink()
62 changes: 59 additions & 3 deletions trcli/readers/robot_xml.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
from datetime import datetime, timedelta
from beartype.typing import List
from beartype.typing import List, Union
from pathlib import Path
from xml.etree import ElementTree
import glob

from trcli.backports import removeprefix
from trcli.cli import Environment
Expand All @@ -21,6 +23,54 @@ def __init__(self, environment: Environment):
super().__init__(environment)
self.case_matcher = environment.case_matcher

@staticmethod
def check_file(filepath: Union[str, Path]) -> Path:
"""Check and process file path, supporting glob patterns.

If the filepath contains glob patterns (*, ?, []), expand them:
- Single file match: Return that file path
- Multiple file matches: Merge the files and return merged file path
- No matches: Raise FileNotFoundError
"""
filepath = Path(filepath)

# Check if this is a glob pattern (contains wildcards)
filepath_str = str(filepath)
if any(char in filepath_str for char in ["*", "?", "["]):
# Expand glob pattern
files = glob.glob(filepath_str, recursive=True)

if not files:
raise FileNotFoundError(f"File not found: {filepath}")
elif len(files) == 1:
# Single file match - return it directly
return Path().cwd().joinpath(files[0])
else:
# Multiple files - merge them
merged_root = ElementTree.Element("robot", generator="Robot 7.0 (merged)")

for file_path in files:
tree = ElementTree.parse(file_path)
root = tree.getroot()

# Merge all <suite> elements from each file
for suite in root.findall("suite"):
merged_root.append(suite)

# Write merged XML to a file
merged_tree = ElementTree.ElementTree(merged_root)
merged_file_path = Path.cwd() / "Merged-Robot-report.xml"

# Use UTF-8 encoding explicitly
merged_tree.write(merged_file_path, encoding="utf-8", xml_declaration=True)

return merged_file_path
else:
# Not a glob pattern - use parent class behavior
if not filepath.is_file():
raise FileNotFoundError(f"File not found: {filepath}")
return filepath

def parse_file(self) -> List[TestRailSuite]:
self.env.log(f"Parsing Robot Framework report.")
tree = ElementTree.parse(self.filepath)
Expand All @@ -46,8 +96,14 @@ def _find_suites(self, suite_element, sections_list: List, namespace=""):
namespace += f".{name}" if namespace else name
tests = suite_element.findall("test")
if tests:
section = TestRailSection(namespace)
sections_list.append(section)
# Check if section with this namespace already exists (for merged files with duplicate suites)
section = next((s for s in sections_list if s.name == namespace), None)
if section is None:
# Create new section if it doesn't exist
section = TestRailSection(namespace)
sections_list.append(section)
# else: reuse existing section and add tests to it

for test in tests:
case_id = None
case_name = test.get("name")
Expand Down