Latest Threat Research:SANDWORM_MODE: Shai-Hulud-Style npm Worm Hijacks CI Workflows and Poisons AI Toolchains.Details
Socket
Book a DemoInstallSign in
Socket

trcli

Package Overview
Dependencies
Maintainers
1
Versions
45
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

trcli - npm Package Compare versions

Comparing version
1.13.0
to
1.13.1
+277
tests/test_multiple_case_ids.py
"""
Unit tests for multiple case ID feature (GitHub #343)
Tests the ability to map a single JUnit test to multiple TestRail case IDs
using comma-separated values in the test_id property.
"""
import pytest
from trcli.readers.junit_xml import JunitParser
class TestParseMultipleCaseIds:
"""Test cases for JunitParser._parse_multiple_case_ids static method"""
@pytest.mark.parametrize(
"input_value, expected_output",
[
# Single case ID (backwards compatibility)
("C123", 123),
("c123", 123),
("123", 123),
(" C123 ", 123),
(" 123 ", 123),
# Multiple case IDs
("C123, C456, C789", [123, 456, 789]),
("C123,C456,C789", [123, 456, 789]),
("123, 456, 789", [123, 456, 789]),
("123,456,789", [123, 456, 789]),
# Mixed case
("c123, C456, c789", [123, 456, 789]),
# Whitespace variations
("C123 , C456 , C789", [123, 456, 789]),
(" C123 , C456 , C789 ", [123, 456, 789]),
("C123 , C456 , C789", [123, 456, 789]),
# Deduplication
("C123, C123", 123), # Returns single int when deduplicated to one
("C123, C456, C123", [123, 456]),
("C100, C200, C100, C300, C200", [100, 200, 300]),
# Invalid inputs (should be ignored)
("C123, invalid, C456", [123, 456]),
("C123, , C456", [123, 456]), # Empty part
("C123, C, C456", [123, 456]), # C without number
("C123, abc, C456", [123, 456]),
("invalid", None),
("", None),
(" ", None),
(",,,", None),
# Edge cases
("C1", 1),
("C999999", 999999),
("C1, C2, C3", [1, 2, 3]),
("1,2,3,4,5", [1, 2, 3, 4, 5]),
],
)
def test_parse_multiple_case_ids(self, input_value, expected_output):
"""Test parsing of single and multiple case IDs"""
result = JunitParser._parse_multiple_case_ids(input_value)
assert result == expected_output, f"Failed for input: '{input_value}'"
def test_parse_multiple_case_ids_none_input(self):
"""Test handling of None input"""
result = JunitParser._parse_multiple_case_ids(None)
assert result is None
def test_parse_multiple_case_ids_very_long_list(self):
"""Test handling of very long lists (100+ case IDs)"""
# Create a list of 150 case IDs
case_ids = [f"C{i}" for i in range(1, 151)]
input_value = ", ".join(case_ids)
result = JunitParser._parse_multiple_case_ids(input_value)
assert isinstance(result, list)
assert len(result) == 150
assert result[0] == 1
assert result[-1] == 150
def test_parse_multiple_case_ids_preserves_order(self):
"""Test that order is preserved when parsing multiple IDs"""
result = JunitParser._parse_multiple_case_ids("C789, C123, C456")
assert result == [789, 123, 456], "Order should be preserved"
def test_parse_multiple_case_ids_mixed_valid_invalid(self):
"""Test handling of mixed valid and invalid case IDs"""
# Should extract only valid IDs and ignore invalid ones
result = JunitParser._parse_multiple_case_ids("C123, invalid, C456, abc, C789, xyz")
assert result == [123, 456, 789]
def test_parse_multiple_case_ids_special_characters(self):
"""Test that special characters are handled correctly"""
# These should not be parsed as valid case IDs
assert JunitParser._parse_multiple_case_ids("C123!, C456#") is None
assert JunitParser._parse_multiple_case_ids("C-123, C+456") is None
def test_backwards_compatibility_single_id(self):
"""Ensure single case ID returns integer (not list) for backwards compatibility"""
# Single IDs should return int, not list
assert JunitParser._parse_multiple_case_ids("C123") == 123
assert not isinstance(JunitParser._parse_multiple_case_ids("C123"), list)
# Single ID after deduplication should also return int
assert JunitParser._parse_multiple_case_ids("C123, C123, C123") == 123
assert not isinstance(JunitParser._parse_multiple_case_ids("C123, C123, C123"), list)
class TestMultipleCaseIdsIntegration:
"""Integration tests for multiple case ID feature with JUnit XML parsing"""
@pytest.fixture
def mock_environment(self, mocker, tmp_path):
"""Create a mock environment for testing"""
# Create a dummy XML file
xml_file = tmp_path / "test.xml"
xml_file.write_text(
'<testsuites><testsuite name="test"><testcase name="test" classname="Test"/></testsuite></testsuites>'
)
env = mocker.Mock()
env.case_matcher = "property"
env.special_parser = None
env.params_from_config = {}
env.file = str(xml_file)
return env
def test_extract_single_case_id_property(self, mock_environment, mocker):
"""Test extraction of single case ID from property (backwards compatibility)"""
parser = JunitParser(mock_environment)
# Mock a testcase with single test_id property
mock_case = mocker.Mock()
mock_case.name = "test_example"
mock_prop = mocker.Mock()
mock_prop.name = "test_id"
mock_prop.value = "C123"
mock_props = mocker.Mock()
mock_props.iterchildren.return_value = [mock_prop]
mock_case.iterchildren.return_value = [mock_props]
case_id, case_name = parser._extract_case_id_and_name(mock_case)
assert case_id == 123
assert case_name == "test_example"
def test_extract_multiple_case_ids_property(self, mock_environment, mocker):
"""Test extraction of multiple case IDs from property"""
parser = JunitParser(mock_environment)
# Mock a testcase with multiple test_ids
mock_case = mocker.Mock()
mock_case.name = "test_combined_scenario"
mock_prop = mocker.Mock()
mock_prop.name = "test_id"
mock_prop.value = "C123, C456, C789"
mock_props = mocker.Mock()
mock_props.iterchildren.return_value = [mock_prop]
mock_case.iterchildren.return_value = [mock_props]
case_id, case_name = parser._extract_case_id_and_name(mock_case)
assert case_id == [123, 456, 789]
assert case_name == "test_combined_scenario"
def test_multiple_case_ids_ignored_for_name_matcher(self, mock_environment, mocker):
"""Test that multiple case IDs in property are ignored when using name matcher"""
mock_environment.case_matcher = "name"
parser = JunitParser(mock_environment)
# When using name matcher, we parse from the name, not the property
mock_case = mocker.Mock()
mock_case.name = "test_C100_example"
mock_case.iterchildren.return_value = []
# Mock the MatchersParser.parse_name_with_id
with mocker.patch(
"trcli.readers.junit_xml.MatchersParser.parse_name_with_id", return_value=(100, "test_example")
):
case_id, case_name = parser._extract_case_id_and_name(mock_case)
# With name matcher, it should extract from name (not property)
assert case_id == 100
assert case_name == "test_example"
class TestMultipleCaseIdsEndToEnd:
"""End-to-end tests for multiple case ID feature with real JUnit XML"""
@pytest.fixture
def mock_environment(self, mocker):
"""Create a mock environment for end-to-end testing"""
env = mocker.Mock()
env.case_matcher = "property"
env.special_parser = None
env.params_from_config = {}
env.file = "tests/test_data/XML/multiple_case_ids_in_property.xml"
env.suite_name = None
return env
def test_parse_junit_xml_with_multiple_case_ids(self, mock_environment):
"""Test end-to-end parsing of JUnit XML with multiple case IDs"""
parser = JunitParser(mock_environment)
suites = parser.parse_file()
assert suites is not None
assert len(suites) > 0
# Get all test cases across all suites and sections
all_test_cases = []
for suite in suites:
for section in suite.testsections:
all_test_cases.extend(section.testcases)
# We should have 8 test cases total:
# - Test 1: 1 case (C1050381)
# - Test 2: 3 cases (C1050382, C1050383, C1050384)
# - Test 3: 4 cases (C1050385, C1050386, C1050387, C1050388)
assert len(all_test_cases) == 8
# Find test cases by case_id
case_ids = [tc.case_id for tc in all_test_cases]
assert 1050381 in case_ids # Single case ID
# Multiple case IDs from test 2
assert 1050382 in case_ids
assert 1050383 in case_ids
assert 1050384 in case_ids
# Multiple case IDs from test 3
assert 1050385 in case_ids
assert 1050386 in case_ids
assert 1050387 in case_ids
assert 1050388 in case_ids
# Verify that test cases with same source test have same title
combined_test_cases = [tc for tc in all_test_cases if tc.case_id in [1050382, 1050383, 1050384]]
assert len(combined_test_cases) == 3
assert combined_test_cases[0].title == combined_test_cases[1].title == combined_test_cases[2].title
# Verify all combined test cases have the same result status
assert combined_test_cases[0].result.status_id == combined_test_cases[1].result.status_id
assert combined_test_cases[1].result.status_id == combined_test_cases[2].result.status_id
# Verify comment is preserved across all cases
if combined_test_cases[0].result.comment:
assert "Combined test covering multiple scenarios" in combined_test_cases[0].result.comment
assert combined_test_cases[0].result.comment == combined_test_cases[1].result.comment
def test_multiple_case_ids_all_get_same_result(self, mock_environment):
"""Verify that all case IDs from one test get the same result data"""
parser = JunitParser(mock_environment)
suites = parser.parse_file()
# Get test cases for C1050382, C1050383, C1050384 (from the same JUnit test)
all_test_cases = []
for suite in suites:
for section in suite.testsections:
all_test_cases.extend(section.testcases)
combined_cases = [tc for tc in all_test_cases if tc.case_id in [1050382, 1050383, 1050384]]
assert len(combined_cases) == 3
# All should have same status
statuses = [tc.result.status_id for tc in combined_cases]
assert len(set(statuses)) == 1, "All cases should have the same status"
# All should have same elapsed time
elapsed_times = [tc.result.elapsed for tc in combined_cases]
assert len(set(elapsed_times)) == 1, "All cases should have the same elapsed time"
# 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"
+1
-1
Metadata-Version: 2.4
Name: trcli
Version: 1.13.0
Version: 1.13.1
License-File: LICENSE.md

@@ -5,0 +5,0 @@ Requires-Dist: click<8.2.2,>=8.1.0

Metadata-Version: 2.4
Name: trcli
Version: 1.13.0
Version: 1.13.1
License-File: LICENSE.md

@@ -5,0 +5,0 @@ Requires-Dist: click<8.2.2,>=8.1.0

@@ -27,2 +27,3 @@ LICENSE.md

tests/test_matchers_parser.py
tests/test_multiple_case_ids.py
tests/test_project_based_client.py

@@ -29,0 +30,0 @@ tests/test_response_verify.py

@@ -1,1 +0,1 @@

__version__ = "1.13.0"
__version__ = "1.13.1"

@@ -90,4 +90,17 @@ """

)
# Validate that we have test cases to include in the run
# Empty runs are not allowed unless include_all is True
if not include_all and (not add_run_data.get("case_ids") or len(add_run_data["case_ids"]) == 0):
error_msg = (
"Cannot create test run: No test cases were matched.\n"
" - For parse_junit: Ensure tests have automation_id/test ids that matches existing cases in TestRail\n"
" - For parse_cucumber: Ensure features have names or @C test id tag matching the existing BDD cases"
)
return None, error_msg
if not plan_id:
response = self.client.send_post(f"add_run/{project_id}", add_run_data)
if response.error_message:
return None, response.error_message
run_id = response.response_text.get("id")

@@ -106,2 +119,4 @@ else:

response = self.client.send_post(f"add_plan_entry/{plan_id}", entry_data)
if response.error_message:
return None, response.error_message
run_id = response.response_text["runs"][0]["id"]

@@ -108,0 +123,0 @@ return run_id, response.error_message

@@ -12,4 +12,6 @@ import re, ast

@staticmethod
def parse_name_with_id(case_name: str) -> Tuple[int, str]:
def parse_name_with_id(case_name: str) -> Tuple[Union[int, List[int], None], str]:
"""Parses case names expecting an ID following one of the following patterns:
Single ID patterns:
- "C123 my test case"

@@ -25,5 +27,38 @@ - "my test case C123"

Multiple ID patterns:
- "[C123, C456, C789] my test case"
- "my test case [C123, C456, C789]"
- "C123_C456_C789_my_test_case" (underscore-separated)
:param case_name: Name of the test case
:return: Tuple with test case ID and test case name without the ID
:return: Tuple with test case ID(s) (int for single, List[int] for multiple) and test case name without the ID(s)
"""
# First, try to parse brackets for single or multiple IDs
results = re.findall(r"\[(.*?)\]", case_name)
for result in results:
# Check if it contains comma-separated IDs
if "," in result:
# Multiple IDs in brackets: [C123, C456, C789]
case_ids = MatchersParser._parse_multiple_case_ids_from_string(result)
if case_ids:
id_tag = f"[{result}]"
tag_idx = case_name.find(id_tag)
cleaned_name = f"{case_name[0:tag_idx].strip()} {case_name[tag_idx + len(id_tag):].strip()}".strip()
# Return list for multiple IDs, int for single ID (backwards compatibility)
return case_ids if len(case_ids) > 1 else case_ids[0], cleaned_name
elif result.lower().startswith("c"):
# Single ID in brackets: [C123]
case_id = result[1:]
if case_id.isnumeric():
id_tag = f"[{result}]"
tag_idx = case_name.find(id_tag)
cleaned_name = f"{case_name[0:tag_idx].strip()} {case_name[tag_idx + len(id_tag):].strip()}".strip()
return int(case_id), cleaned_name
# Try underscore-separated multiple IDs: C123_C456_C789_test_name
underscore_case_ids = MatchersParser._parse_multiple_underscore_ids(case_name)
if underscore_case_ids:
return underscore_case_ids
# Fall back to original space/underscore single ID parsing
for char in [" ", "_"]:

@@ -35,3 +70,3 @@ parts = case_name.split(char)

id_part = part[1:]
id_part_clean = re.sub(r'\(.*\)$', '', id_part)
id_part_clean = re.sub(r"\(.*\)$", "", id_part)
if id_part_clean.isnumeric():

@@ -41,15 +76,72 @@ parts_copy.pop(idx)

results = re.findall(r"\[(.*?)\]", case_name)
for result in results:
if result.lower().startswith("c"):
case_id = result[1:]
if case_id.isnumeric():
id_tag = f"[{result}]"
tag_idx = case_name.find(id_tag)
case_name = f"{case_name[0:tag_idx].strip()} {case_name[tag_idx + len(id_tag):].strip()}".strip()
return int(case_id), case_name
return None, case_name
@staticmethod
def _parse_multiple_case_ids_from_string(ids_string: str) -> List[int]:
"""
Parse comma-separated case IDs from a string.
Examples:
- "C123, C456, C789" -> [123, 456, 789]
- "123, 456, 789" -> [123, 456, 789]
- " C123 , C456 " -> [123, 456]
:param ids_string: String containing comma-separated case IDs
:return: List of integer case IDs
"""
case_ids = []
parts = [part.strip() for part in ids_string.split(",")]
for part in parts:
if not part:
continue
# Remove 'C' or 'c' prefix if present
cleaned = part.lower().replace("c", "", 1).strip()
# Check if it's a valid numeric ID
if cleaned.isdigit():
case_id = int(cleaned)
# Deduplicate
if case_id not in case_ids:
case_ids.append(case_id)
return case_ids
@staticmethod
def _parse_multiple_underscore_ids(case_name: str) -> Union[Tuple[List[int], str], Tuple[int, str], None]:
"""
Parse multiple underscore-separated case IDs from test name.
Examples:
- "C123_C456_C789_test_name" -> ([123, 456, 789], "test_name")
- "C100_C200_my_test" -> ([100, 200], "my_test")
:param case_name: Test case name
:return: Tuple with case IDs and cleaned name, or None if no multiple IDs found
"""
parts = case_name.split("_")
case_ids = []
non_id_parts = []
for part in parts:
if part.lower().startswith("c") and len(part) > 1:
id_part = part[1:]
# Remove parentheses (JUnit 5 support)
id_part_clean = re.sub(r"\(.*\)$", "", id_part)
if id_part_clean.isdigit():
case_id = int(id_part_clean)
if case_id not in case_ids:
case_ids.append(case_id)
continue
non_id_parts.append(part)
# Only return if we found at least 2 case IDs
if len(case_ids) >= 2:
cleaned_name = "_".join(non_id_parts)
return case_ids, cleaned_name
return None
class FieldsParser:

@@ -79,2 +171,3 @@

class TestRailCaseFieldsOptimizer:

@@ -90,7 +183,7 @@

# Define delimiters for splitting words
delimiters = [' ', '\t', ';', ':', '>', '/', '.']
delimiters = [" ", "\t", ";", ":", ">", "/", "."]
# Replace multiple consecutive delimiters with a single space
regex_pattern = '|'.join(map(re.escape, delimiters))
cleaned_string = re.sub(f'[{regex_pattern}]+', ' ', input_string.strip())
regex_pattern = "|".join(map(re.escape, delimiters))
cleaned_string = re.sub(f"[{regex_pattern}]+", " ", input_string.strip())

@@ -111,3 +204,3 @@ # Split the cleaned string into words

# Reverse the extracted words to maintain the original order
result = ' '.join(reversed(extracted_words))
result = " ".join(reversed(extracted_words))

@@ -118,2 +211,2 @@ # as fallback, return the last characters if the result is empty

return result
return result

@@ -110,3 +110,3 @@ import glob

if prop.name == "test_id":
case_id = int(prop.value.lower().replace("c", ""))
case_id = self._parse_multiple_case_ids(prop.value)
return case_id, case_name

@@ -116,2 +116,61 @@

@staticmethod
def _parse_multiple_case_ids(test_id_value: str) -> Union[int, List[int], None]:
"""
Parse single or multiple case IDs from a test_id property value.
Supports comma-separated case IDs for mapping multiple TestRail cases to one JUnit test.
Examples:
- "C123" -> 123 (int)
- "C123, C456, C789" -> [123, 456, 789] (list)
- "123, 456, 789" -> [123, 456, 789] (list)
- " C123 , C456 " -> [123, 456] (list)
- "C123, C123" -> 123 (int, deduplicated)
:param test_id_value: Value of the test_id property
:return: Single case ID (int), multiple case IDs (List[int]), or None if invalid
"""
if not test_id_value or not isinstance(test_id_value, str):
return None
test_id_value = test_id_value.strip()
if not test_id_value:
return None
# Check if comma-separated (multiple IDs)
if "," in test_id_value:
case_ids = []
parts = [part.strip() for part in test_id_value.split(",")]
for part in parts:
if not part:
continue
# Remove 'C' or 'c' prefix if present
cleaned = part.lower().replace("c", "", 1).strip()
# Check if it's a valid numeric ID
if cleaned.isdigit():
case_id = int(cleaned)
# Deduplicate
if case_id not in case_ids:
case_ids.append(case_id)
# Return None if no valid IDs found
if not case_ids:
return None
# Return int for single ID (backwards compatibility after deduplication)
elif len(case_ids) == 1:
return case_ids[0]
# Return list for multiple IDs
else:
return case_ids
else:
# Single case ID (original behavior)
cleaned = test_id_value.lower().replace("c", "", 1).strip()
if cleaned.isdigit():
return int(cleaned)
return None
def _get_status_id_for_case_result(self, case: JUnitTestCase) -> Union[int, None]:

@@ -207,44 +266,90 @@ if case.is_passed:

comment = self._get_comment_for_case_result(case)
result = TestRailResult(
case_id=case_id,
elapsed=case.time,
attachments=attachments,
result_fields=result_fields_dict,
custom_step_results=result_steps,
status_id=status_id,
comment=comment,
)
for comment in reversed(comments):
result.prepend_comment(comment)
if sauce_session:
result.prepend_comment(f"SauceLabs session: {sauce_session}")
automation_id = case_fields_dict.pop(OLD_SYSTEM_NAME_AUTOMATION_ID, None) or case._elem.get(
# Prepare data that will be shared across all case IDs (if multiple)
base_automation_id = case_fields_dict.pop(OLD_SYSTEM_NAME_AUTOMATION_ID, None) or case._elem.get(
OLD_SYSTEM_NAME_AUTOMATION_ID, automation_id
)
base_title = TestRailCaseFieldsOptimizer.extract_last_words(
case_name, TestRailCaseFieldsOptimizer.MAX_TESTCASE_TITLE_LENGTH
)
# Create TestRailCase kwargs
case_kwargs = {
"title": TestRailCaseFieldsOptimizer.extract_last_words(
case_name, TestRailCaseFieldsOptimizer.MAX_TESTCASE_TITLE_LENGTH
),
"case_id": case_id,
"result": result,
"custom_automation_id": automation_id,
"case_fields": case_fields_dict,
}
# Check if case_id is a list (multiple IDs) or single value
if isinstance(case_id, list):
# Multiple case IDs: create a TestRailCase for each ID with same result data
for individual_case_id in case_id:
# Create a new result object for each case (avoid sharing references)
result = TestRailResult(
case_id=individual_case_id,
elapsed=case.time,
attachments=attachments.copy() if attachments else [],
result_fields=result_fields_dict.copy(),
custom_step_results=result_steps.copy() if result_steps else [],
status_id=status_id,
comment=comment,
)
# Only set refs field if case_refs has actual content
if case_refs and case_refs.strip():
case_kwargs["refs"] = case_refs
# Apply comment prepending
for comment_text in reversed(comments):
result.prepend_comment(comment_text)
if sauce_session:
result.prepend_comment(f"SauceLabs session: {sauce_session}")
test_case = TestRailCase(**case_kwargs)
# Create TestRailCase kwargs
case_kwargs = {
"title": base_title,
"case_id": individual_case_id,
"result": result,
"custom_automation_id": base_automation_id,
"case_fields": case_fields_dict.copy(),
}
# Store JUnit references as a temporary attribute for case updates (not serialized)
if case_refs and case_refs.strip():
test_case._junit_case_refs = case_refs
# Only set refs field if case_refs has actual content
if case_refs and case_refs.strip():
case_kwargs["refs"] = case_refs
test_cases.append(test_case)
test_case = TestRailCase(**case_kwargs)
# Store JUnit references as a temporary attribute for case updates (not serialized)
if case_refs and case_refs.strip():
test_case._junit_case_refs = case_refs
test_cases.append(test_case)
else:
# Single case ID: existing behavior (backwards compatibility)
result = TestRailResult(
case_id=case_id,
elapsed=case.time,
attachments=attachments,
result_fields=result_fields_dict,
custom_step_results=result_steps,
status_id=status_id,
comment=comment,
)
for comment_text in reversed(comments):
result.prepend_comment(comment_text)
if sauce_session:
result.prepend_comment(f"SauceLabs session: {sauce_session}")
# Create TestRailCase kwargs
case_kwargs = {
"title": base_title,
"case_id": case_id,
"result": result,
"custom_automation_id": base_automation_id,
"case_fields": case_fields_dict,
}
# Only set refs field if case_refs has actual content
if case_refs and case_refs.strip():
case_kwargs["refs"] = case_refs
test_case = TestRailCase(**case_kwargs)
# Store JUnit references as a temporary attribute for case updates (not serialized)
if case_refs and case_refs.strip():
test_case._junit_case_refs = case_refs
test_cases.append(test_case)
return test_cases

@@ -251,0 +356,0 @@

Sorry, the diff of this file is too big to display