From 73084f9fb90e79d1aca026ee6dfb30494637cf6b Mon Sep 17 00:00:00 2001 From: acuanico-tr-galt Date: Tue, 19 May 2026 14:17:46 +0800 Subject: [PATCH 1/5] TRCLI-266: Updated attachment uploading object identity mapping logic, also updated uploading attachments to use threadpool executor to parallelize and speed up uploading. --- trcli/api/api_request_handler.py | 15 +++- trcli/api/result_handler.py | 123 ++++++++++++++++++++++--------- 2 files changed, 100 insertions(+), 38 deletions(-) diff --git a/trcli/api/api_request_handler.py b/trcli/api/api_request_handler.py index 524fd501..0f4db53b 100644 --- a/trcli/api/api_request_handler.py +++ b/trcli/api/api_request_handler.py @@ -286,13 +286,16 @@ def update_existing_case_references( ) -> Tuple[bool, str, List[str], List[str], List[str]]: return self.case_handler.update_existing_case_references(case_id, junit_refs, case_fields, strategy) - def upload_attachments(self, report_results: List[Dict], request_id_to_result_id: Dict[int, int]): - return self.result_handler.upload_attachments(report_results, request_id_to_result_id) + def upload_attachments( + self, report_results: List[Dict], request_id_to_result_id: Dict[int, int], total_attachments: int + ): + return self.result_handler.upload_attachments(report_results, request_id_to_result_id, total_attachments) def add_results(self, run_id: int) -> Tuple[List, str, int]: return self.result_handler.add_results(run_id) def handle_futures(self, futures, action_string, progress_bar) -> Tuple[list, str]: + responses_by_request = {} if action_string == "add_results" else None responses = [] error_message = "" try: @@ -300,10 +303,11 @@ def handle_futures(self, futures, action_string, progress_bar) -> Tuple[list, st arguments = futures[future] response = future.result() if not response.error_message: - responses.append(response) if action_string == "add_results": + responses_by_request[id(arguments)] = response progress_bar.update(len(arguments["results"])) else: + responses.append(response) if action_string == "add_case": arguments = arguments.to_dict() arguments.pop("case_id") @@ -323,6 +327,11 @@ def handle_futures(self, futures, action_string, progress_bar) -> Tuple[list, st except KeyboardInterrupt: self.__cancel_running_futures(futures, action_string) raise KeyboardInterrupt + + if action_string == "add_results" and responses_by_request: + request_bodies = list(futures.values()) + responses = [responses_by_request[id(req)] for req in request_bodies if id(req) in responses_by_request] + return responses, error_message def close_run(self, run_id: int) -> Tuple[dict, str]: diff --git a/trcli/api/result_handler.py b/trcli/api/result_handler.py index 56a39e33..fc80330c 100644 --- a/trcli/api/result_handler.py +++ b/trcli/api/result_handler.py @@ -8,6 +8,7 @@ """ import os +import threading from concurrent.futures import ThreadPoolExecutor, as_completed from beartype.typing import List, Tuple, Dict @@ -44,14 +45,59 @@ def __init__( self.__get_all_tests_in_run = get_all_tests_in_run_callback self.handle_futures = handle_futures_callback - def upload_attachments(self, report_results: List[Dict], request_id_to_result_id: Dict[int, int]): + def _upload_single_attachment(self, file_path: str, result_id: int, case_id: int) -> Tuple[bool, str]: """ - Upload attachments to test results. + Upload a single attachment file. + + :param file_path: Path to the attachment file + :param result_id: TestRail result ID to attach to + :param case_id: TestRail case ID (for error messages) + :return: Tuple of (success, error_message) + """ + try: + with open(file_path, "rb") as file: + response = self.client.send_post(f"add_attachment_to_result/{result_id}", files={"attachment": file}) + + # Check if upload was successful + if response.status_code != 200: + file_name = os.path.basename(file_path) + + # Handle 413 Request Entity Too Large specifically + if response.status_code == 413: + error_msg = FAULT_MAPPING["attachment_too_large"].format(file_name=file_name, case_id=case_id) + return False, f"{file_name} (case {case_id})" + else: + # Handle other HTTP errors + error_msg = FAULT_MAPPING["attachment_upload_failed"].format( + file_path=file_name, + case_id=case_id, + error_message=response.error_message or f"HTTP {response.status_code}", + ) + return False, f"{file_name} (case {case_id})" + return True, None + + except FileNotFoundError: + return False, f"{file_path} (case {case_id})" + except Exception as ex: + file_name = os.path.basename(file_path) if os.path.exists(file_path) else file_path + return False, f"{file_name} (case {case_id})" + + def upload_attachments( + self, report_results: List[Dict], request_id_to_result_id: Dict[int, int], total_attachments: int + ): + """ + Upload attachments to test results concurrently. :param report_results: List of test results with attachments from report :param request_id_to_result_id: Mapping from request object id to result_id + :param total_attachments: Total number of attachments to upload """ failed_uploads = [] + uploaded_count = 0 + count_lock = threading.Lock() + + # Prepare list of upload tasks + upload_tasks = [] for report_result in report_results: case_id = report_result["case_id"] # Use object identity to find the correct result_id for THIS specific result @@ -62,39 +108,49 @@ def upload_attachments(self, report_results: List[Dict], request_id_to_result_id continue for file_path in report_result.get("attachments"): + upload_tasks.append((file_path, result_id, case_id)) + + if not upload_tasks: + return + + # Use ThreadPoolExecutor for concurrent uploads + with ThreadPoolExecutor(max_workers=min(10, len(upload_tasks))) as executor: + # Submit all upload tasks + future_to_task = { + executor.submit(self._upload_single_attachment, file_path, result_id, case_id): (file_path, case_id) + for file_path, result_id, case_id in upload_tasks + } + + # Process completed uploads + for future in as_completed(future_to_task): + file_path, case_id = future_to_task[future] try: - with open(file_path, "rb") as file: - response = self.client.send_post( - f"add_attachment_to_result/{result_id}", files={"attachment": file} + success, error_msg = future.result() + + with count_lock: + if success: + uploaded_count += 1 + else: + if error_msg: + failed_uploads.append(error_msg) + # Log errors to stderr + file_name = os.path.basename(file_path) + self.environment.elog(f"Failed to upload attachment '{file_name}' for case {case_id}") + + # Update progress in place (overwrite the line) + self.environment.log( + f"\rUploading {uploaded_count}/{total_attachments} for {len(report_results)} test results.", + new_line=False, ) - # Check if upload was successful - if response.status_code != 200: - file_name = os.path.basename(file_path) - - # Handle 413 Request Entity Too Large specifically - if response.status_code == 413: - error_msg = FAULT_MAPPING["attachment_too_large"].format( - file_name=file_name, case_id=case_id - ) - self.environment.elog(error_msg) - failed_uploads.append(f"{file_name} (case {case_id})") - else: - # Handle other HTTP errors - error_msg = FAULT_MAPPING["attachment_upload_failed"].format( - file_path=file_name, - case_id=case_id, - error_message=response.error_message or f"HTTP {response.status_code}", - ) - self.environment.elog(error_msg) - failed_uploads.append(f"{file_name} (case {case_id})") - except FileNotFoundError: - self.environment.elog(f"Attachment file not found: {file_path} (case {case_id})") - failed_uploads.append(f"{file_path} (case {case_id})") except Exception as ex: - file_name = os.path.basename(file_path) if os.path.exists(file_path) else file_path - self.environment.elog(f"Error uploading attachment '{file_name}' for case {case_id}: {ex}") - failed_uploads.append(f"{file_name} (case {case_id})") + with count_lock: + file_name = os.path.basename(file_path) if os.path.exists(file_path) else file_path + self.environment.elog(f"Error uploading attachment '{file_name}' for case {case_id}: {ex}") + failed_uploads.append(f"{file_name} (case {case_id})") + + # Print newline after progress is complete + self.environment.log("") # Provide a summary if there were failed uploads if failed_uploads: @@ -156,10 +212,7 @@ def add_results(self, run_id: int) -> Tuple[List, str, int]: attachments_count = 0 for result in report_results_w_attachments: attachments_count += len(result["attachments"]) - self.environment.log( - f"Uploading {attachments_count} attachments " f"for {len(report_results_w_attachments)} test results." - ) - self.upload_attachments(report_results_w_attachments, request_id_to_result_id) + self.upload_attachments(report_results_w_attachments, request_id_to_result_id, attachments_count) else: self.environment.log(f"No attachments found to upload.") From bbad7e120ea9fe1ad94b2beaacb00945d8fd7b0c Mon Sep 17 00:00:00 2001 From: acuanico-tr-galt Date: Tue, 19 May 2026 14:19:01 +0800 Subject: [PATCH 2/5] TRCLI-266: Updated affected unit and functional tests for updated attachment processing and uploading logic --- tests/test_api_request_handler.py | 15 ++++++++++----- tests_e2e/test_end2end.py | 30 +++++++++++++++--------------- 2 files changed, 25 insertions(+), 20 deletions(-) diff --git a/tests/test_api_request_handler.py b/tests/test_api_request_handler.py index 0a678670..b63f95e3 100644 --- a/tests/test_api_request_handler.py +++ b/tests/test_api_request_handler.py @@ -1380,7 +1380,8 @@ def test_upload_attachments_413_error(self, api_request_handler: ApiRequestHandl request_id_to_result_id = {id(report_results[0]): 2001} # Call upload_attachments - api_request_handler.upload_attachments(report_results, request_id_to_result_id) + total_attachments = sum(len(r["attachments"]) for r in report_results) + api_request_handler.upload_attachments(report_results, request_id_to_result_id, total_attachments) # Verify the request was made (case-insensitive comparison) assert requests_mock.last_request.url.lower() == create_url("add_attachment_to_result/2001").lower() @@ -1400,7 +1401,8 @@ def test_upload_attachments_success(self, api_request_handler: ApiRequestHandler request_id_to_result_id = {id(report_results[0]): 2001} # Call upload_attachments - api_request_handler.upload_attachments(report_results, request_id_to_result_id) + total_attachments = sum(len(r["attachments"]) for r in report_results) + api_request_handler.upload_attachments(report_results, request_id_to_result_id, total_attachments) # Verify the request was made (case-insensitive comparison) assert requests_mock.last_request.url.lower() == create_url("add_attachment_to_result/2001").lower() @@ -1413,7 +1415,8 @@ def test_upload_attachments_file_not_found(self, api_request_handler: ApiRequest request_id_to_result_id = {id(report_results[0]): 2001} # Call upload_attachments - should not raise exception - api_request_handler.upload_attachments(report_results, request_id_to_result_id) + total_attachments = sum(len(r["attachments"]) for r in report_results) + api_request_handler.upload_attachments(report_results, request_id_to_result_id, total_attachments) @pytest.mark.api_handler def test_upload_attachments_empty_run_scenario( @@ -1446,7 +1449,8 @@ def test_upload_attachments_empty_run_scenario( request_id_to_result_id = {id(report_results[0]): 5001, id(report_results[1]): 5002} # Call upload_attachments - api_request_handler.upload_attachments(report_results, request_id_to_result_id) + total_attachments = sum(len(r["attachments"]) for r in report_results) + api_request_handler.upload_attachments(report_results, request_id_to_result_id, total_attachments) # Verify both attachments were uploaded correctly history = requests_mock.request_history @@ -1487,7 +1491,8 @@ def test_upload_attachments_duplicate_case_ids_different_results( } # Call upload_attachments - api_request_handler.upload_attachments(report_results, request_id_to_result_id) + total_attachments = sum(len(r["attachments"]) for r in report_results) + api_request_handler.upload_attachments(report_results, request_id_to_result_id, total_attachments) # Verify both attachments were uploaded correctly history = requests_mock.request_history diff --git a/tests_e2e/test_end2end.py b/tests_e2e/test_end2end.py index 6afe43e7..726af1e4 100644 --- a/tests_e2e/test_end2end.py +++ b/tests_e2e/test_end2end.py @@ -92,7 +92,7 @@ def test_cli_robot_report_RF50(self): [ "Processed 3 test cases in 2 sections.", f"Creating test run. Test run: {self.TR_INSTANCE}index.php?/runs/view", - "Uploading 1 attachments for 1 test results.", + "Uploading 1/1 for 1 test results", "Submitted 3 test results in", ], ) @@ -113,7 +113,7 @@ def test_cli_robot_report_RF70(self): [ "Processed 3 test cases in 2 sections.", f"Creating test run. Test run: {self.TR_INSTANCE}index.php?/runs/view", - "Uploading 1 attachments for 1 test results.", + "Uploading 1/1 for 1 test results", "Submitted 3 test results in", ], ) @@ -135,7 +135,7 @@ def test_cli_plan_id(self): [ "Processed 3 test cases in section [GENERIC-IDS-AUTO]", f"Creating test run. Test run: {self.TR_INSTANCE}index.php?/runs/view", - "Uploading 1 attachments for 1 test results.", + "Uploading 1/1 for 1 test results", "Submitted 6 test results in", ], ) @@ -158,7 +158,7 @@ def test_cli_plan_id_and_config_id(self): [ "Processed 3 test cases in section [GENERIC-IDS-AUTO]", f"Creating test run. Test run: {self.TR_INSTANCE}index.php?/runs/view", - "Uploading 1 attachments for 1 test results.", + "Uploading 1/1 for 1 test results", "Submitted 6 test results in", ], ) @@ -180,7 +180,7 @@ def test_cli_update_run_in_plan(self): [ "Processed 3 test cases in section [GENERIC-IDS-AUTO]", f"Updating test run. Test run: {self.TR_INSTANCE}index.php?/runs/view", - "Uploading 1 attachments for 1 test results.", + "Uploading 1/1 for 1 test results", "Submitted 6 test results in", ], ) @@ -202,7 +202,7 @@ def test_cli_update_run_in_plan_with_configs(self): [ "Processed 3 test cases in section [GENERIC-IDS-AUTO]", f"Updating test run. Test run: {self.TR_INSTANCE}index.php?/runs/view", - "Uploading 1 attachments for 1 test results.", + "Uploading 1/1 for 1 test results", "Submitted 6 test results in", ], ) @@ -223,7 +223,7 @@ def test_cli_matchers_auto(self): [ "Processed 3 test cases in section [GENERIC-IDS-AUTO]", f"Creating test run. Test run: {self.TR_INSTANCE}index.php?/runs/view", - "Uploading 1 attachments for 1 test results.", + "Uploading 1/1 for 1 test results", "Submitted 6 test results in", ], ) @@ -246,7 +246,7 @@ def test_cli_matchers_auto_update_run(self): [ "Processed 3 test cases in section [GENERIC-IDS-AUTO]", f"Updating test run. Test run: {self.TR_INSTANCE}index.php?/runs/view", - "Uploading 1 attachments for 1 test results.", + "Uploading 1/1 for 1 test results", "Submitted 6 test results in", ], ) @@ -290,7 +290,7 @@ def test_cli_matchers_name(self): "Processed 3 test cases in section [GENERIC-IDS-NAME]", "Found 3 test cases without case ID in the report file.", f"Creating test run. Test run: {self.TR_INSTANCE}index.php?/runs/view", - "Uploading 1 attachments for 1 test results.", + "Uploading 1/1 for 1 test results", "Submitted 3 test results in", ], ) @@ -313,7 +313,7 @@ def test_cli_matchers_property(self): "Processed 3 test cases in section [GENERIC-IDS-PROP]", "Found 3 test cases without case ID in the report file.", f"Creating test run. Test run: {self.TR_INSTANCE}index.php?/runs/view", - "Uploading 1 attachments for 1 test results.", + "Uploading 1/1 for 1 test results", "Submitted 3 test results in", ], ) @@ -334,7 +334,7 @@ def test_cli_attachments(self): [ "Processed 3 test cases in section [ATTACHMENTS]", f"Creating test run. Test run: {self.TR_INSTANCE}index.php?/runs/view", - "Uploading 4 attachments for 2 test results.", + "Uploading 4/4 for 2 test results", "Submitted 3 test results in", ], ) @@ -376,7 +376,7 @@ def test_cli_multiple_case_ids(self): # 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", + "Uploading 5/5 for 5 test results", # Submits results: 1 (single) + 3 (test 2) + 2 (test 3) = 6 total "Submitted 6 test results in", ], @@ -548,7 +548,7 @@ def test_cli_add_run_upload_results(self): output, [ f"Updating test run. Test run: {self.TR_INSTANCE}index.php?/runs/view", - "Uploading 1 attachments for 1 test results.", + "Uploading 1/1 for 1 test results", "Submitted 6 test results", ], ) @@ -755,7 +755,7 @@ def bug_test_cli_robot_description_bug(self): output, [ "Processed 3 test cases in 2 sections.", - "Uploading 1 attachments for 1 test results.", + "Uploading 1/1 for 1 test results", "Submitted 3 test results in", ], ) @@ -776,7 +776,7 @@ def bug_test_automation_id(self): output, [ f"Updating test run. Test run: {self.TR_INSTANCE}index.php?/runs/view", - "Uploading 1 attachments for 1 test results.", + "Uploading 1/1 for 1 test results", "Submitted 6 test results", ], ) From f0e54eb35efe4118db1ca0f24be8b7368fb9ef8b Mon Sep 17 00:00:00 2001 From: acuanico-tr-galt Date: Tue, 26 May 2026 15:11:18 +0800 Subject: [PATCH 3/5] TRCLI-262: Added new results command to handle listing and updating existing test results --- trcli/api/api_request_handler.py | 64 +++++++++++++ trcli/api/result_handler.py | 150 +++++++++++++++++++++++++++++++ 2 files changed, 214 insertions(+) diff --git a/trcli/api/api_request_handler.py b/trcli/api/api_request_handler.py index 0f4db53b..7af9b2fb 100644 --- a/trcli/api/api_request_handler.py +++ b/trcli/api/api_request_handler.py @@ -294,6 +294,70 @@ def upload_attachments( def add_results(self, run_id: int) -> Tuple[List, str, int]: return self.result_handler.add_results(run_id) + def get_results(self, test_id: int, offset: int = 0, limit: int = 250) -> Tuple[List[Dict], str]: + """ + Get test results for a specific test. + + :param test_id: TestRail test ID + :param offset: Pagination offset (default: 0) + :param limit: Pagination limit (default: 250) + :returns: Tuple of (results_list, error_message) + """ + return self.result_handler.get_results(test_id, offset, limit) + + def get_results_for_run(self, run_id: int, offset: int = 0, limit: int = 250) -> Tuple[List[Dict], str]: + """ + Get test results for all tests in a run. + + :param run_id: TestRail run ID + :param offset: Pagination offset (default: 0) + :param limit: Pagination limit (default: 250) + :returns: Tuple of (results_list, error_message) + """ + return self.result_handler.get_results_for_run(run_id, offset, limit) + + def get_results_for_case( + self, run_id: int, case_id: int, offset: int = 0, limit: int = 250 + ) -> Tuple[List[Dict], str]: + """ + Get test results for a specific case in a run. + + :param run_id: TestRail run ID + :param case_id: TestRail case ID + :param offset: Pagination offset (default: 0) + :param limit: Pagination limit (default: 250) + :returns: Tuple of (results_list, error_message) + """ + return self.result_handler.get_results_for_case(run_id, case_id, offset, limit) + + def edit_result( + self, + result_id: int, + status_id: int = None, + comment: str = None, + version: str = None, + elapsed: str = None, + defects: str = None, + assignedto_id: int = None, + custom_fields: Dict = None, + ) -> Tuple[bool, str]: + """ + Edit an existing test result. + + :param result_id: TestRail result ID to edit + :param status_id: Test status ID (1=Passed, 2=Blocked, 3=Untested, 4=Retest, 5=Failed) + :param comment: Comment/notes for the result + :param version: Version or build tested against + :param elapsed: Time elapsed (e.g., "1m 5s" or "65s") + :param defects: Comma-separated list of defect IDs + :param assignedto_id: User ID to assign the test to + :param custom_fields: Dictionary of custom field values + :returns: Tuple of (success, error_message) + """ + return self.result_handler.edit_result( + result_id, status_id, comment, version, elapsed, defects, assignedto_id, custom_fields + ) + def handle_futures(self, futures, action_string, progress_bar) -> Tuple[list, str]: responses_by_request = {} if action_string == "add_results" else None responses = [] diff --git a/trcli/api/result_handler.py b/trcli/api/result_handler.py index fc80330c..8d9458bc 100644 --- a/trcli/api/result_handler.py +++ b/trcli/api/result_handler.py @@ -3,6 +3,7 @@ It manages all test result operations including: - Adding test results +- Editing existing test results - Uploading attachments to results - Retrieving results after cancellation """ @@ -226,6 +227,155 @@ def add_results(self, run_id: int) -> Tuple[List, str, int]: return responses, error_message, progress_bar.n + def get_results(self, test_id: int, offset: int = 0, limit: int = 250) -> Tuple[List[Dict], str]: + """ + Get test results for a specific test. + + :param test_id: TestRail test ID + :param offset: Pagination offset (default: 0) + :param limit: Pagination limit (default: 250) + :returns: Tuple of (results_list, error_message) + """ + # Build API endpoint with pagination + endpoint = f"get_results/{test_id}&offset={offset}&limit={limit}" + + # Make API request + response = self.client.send_get(endpoint) + + if response.error_message: + return [], response.error_message + + # API returns a dict with pagination metadata and 'results' key + response_data = response.response_text + if isinstance(response_data, dict) and "results" in response_data: + results = response_data["results"] + elif isinstance(response_data, list): + # Fallback for direct list response (older API format) + results = response_data + else: + results = [] + + return results, None + + def get_results_for_run(self, run_id: int, offset: int = 0, limit: int = 250) -> Tuple[List[Dict], str]: + """ + Get test results for all tests in a run. + + :param run_id: TestRail run ID + :param offset: Pagination offset (default: 0) + :param limit: Pagination limit (default: 250) + :returns: Tuple of (results_list, error_message) + """ + # Build API endpoint with pagination + endpoint = f"get_results_for_run/{run_id}&offset={offset}&limit={limit}" + + # Make API request + response = self.client.send_get(endpoint) + + if response.error_message: + return [], response.error_message + + # API returns a dict with pagination metadata and 'results' key + response_data = response.response_text + if isinstance(response_data, dict) and "results" in response_data: + results = response_data["results"] + elif isinstance(response_data, list): + # Fallback for direct list response (older API format) + results = response_data + else: + results = [] + + return results, None + + def get_results_for_case( + self, run_id: int, case_id: int, offset: int = 0, limit: int = 250 + ) -> Tuple[List[Dict], str]: + """ + Get test results for a specific case in a run. + + :param run_id: TestRail run ID + :param case_id: TestRail case ID + :param offset: Pagination offset (default: 0) + :param limit: Pagination limit (default: 250) + :returns: Tuple of (results_list, error_message) + """ + # Build API endpoint with pagination + endpoint = f"get_results_for_case/{run_id}/{case_id}&offset={offset}&limit={limit}" + + # Make API request + response = self.client.send_get(endpoint) + + if response.error_message: + return [], response.error_message + + # API returns a dict with pagination metadata and 'results' key + response_data = response.response_text + if isinstance(response_data, dict) and "results" in response_data: + results = response_data["results"] + elif isinstance(response_data, list): + # Fallback for direct list response (older API format) + results = response_data + else: + results = [] + + return results, None + + def edit_result( + self, + result_id: int, + status_id: int = None, + comment: str = None, + version: str = None, + elapsed: str = None, + defects: str = None, + assignedto_id: int = None, + custom_fields: Dict = None, + ) -> Tuple[bool, str]: + """ + Edit an existing test result. + + :param result_id: TestRail result ID to edit + :param status_id: Test status ID (1=Passed, 2=Blocked, 3=Untested, 4=Retest, 5=Failed) + :param comment: Comment/notes for the result + :param version: Version or build tested against + :param elapsed: Time elapsed (e.g., "1m 5s" or "65s") + :param defects: Comma-separated list of defect IDs + :param assignedto_id: User ID to assign the test to + :param custom_fields: Dictionary of custom field values + :returns: Tuple of (success, error_message) + """ + # Build request body with only provided parameters + body = {} + + if status_id is not None: + body["status_id"] = status_id + if comment is not None: + body["comment"] = comment + if version is not None: + body["version"] = version + if elapsed is not None: + body["elapsed"] = elapsed + if defects is not None: + body["defects"] = defects + if assignedto_id is not None: + body["assignedto_id"] = assignedto_id + + # Add custom fields if provided + if custom_fields: + body.update(custom_fields) + + # Validate that at least one field is being updated + if not body: + return False, "No fields provided to update" + + # Make API request + response = self.client.send_post(f"edit_result/{result_id}", payload=body) + + if response.error_message: + return False, response.error_message + + return True, None + @staticmethod def retrieve_results_after_cancelling(futures) -> list: """ From 369307dff542dd534b3714c9f82de7f2fdec6a57 Mon Sep 17 00:00:00 2001 From: acuanico-tr-galt Date: Tue, 26 May 2026 15:12:06 +0800 Subject: [PATCH 4/5] TRCLI-262: Added new results command file --- trcli/commands/cmd_results.py | 335 ++++++++++++++++++++++++++++++++++ 1 file changed, 335 insertions(+) create mode 100644 trcli/commands/cmd_results.py diff --git a/trcli/commands/cmd_results.py b/trcli/commands/cmd_results.py new file mode 100644 index 00000000..f8284366 --- /dev/null +++ b/trcli/commands/cmd_results.py @@ -0,0 +1,335 @@ +import builtins +import click +import json + +from trcli.api.api_client import APIClient +from trcli.api.result_handler import ResultHandler +from trcli.cli import pass_environment, CONTEXT_SETTINGS, Environment + + +def print_config(env: Environment, action: str): + env.log(f"Results {action} Execution Parameters" f"\n> TestRail instance: {env.host} (user: {env.username})") + + +@click.group(context_settings=CONTEXT_SETTINGS) +@click.pass_context +@pass_environment +def cli(environment: Environment, context: click.Context, *args, **kwargs): + """Manage test results in TestRail""" + environment.cmd = "results" + environment.set_parameters(context) + + +@cli.command() +@click.option("--test-id", type=click.IntRange(min=1), metavar="", help="Test ID to retrieve results for.") +@click.option("--run-id", type=click.IntRange(min=1), metavar="", help="Run ID to retrieve results for.") +@click.option( + "--case-id", type=click.IntRange(min=1), metavar="", help="Case ID to retrieve results for (requires --run-id)." +) +@click.option("--offset", type=int, default=0, metavar="", help="Offset for pagination (default: 0).") +@click.option("--limit", type=int, default=250, metavar="", help="Limit for pagination (default: 250).") +@click.option("--json-output", is_flag=True, help="Output results as raw JSON from API.") +@click.option("--show-all-fields", is_flag=True, help="Show all fields including custom fields in detail.") +@click.pass_context +@pass_environment +def list( + environment: Environment, + context: click.Context, + test_id: int, + run_id: int, + case_id: int, + offset: int, + limit: int, + json_output: bool, + show_all_fields: bool, + *args, + **kwargs, +): + """List test results from TestRail""" + print_config(environment, "List") + + # Validate mutually exclusive filters + filters_provided = sum([test_id is not None, run_id is not None, case_id is not None]) + + if filters_provided == 0: + environment.elog("Error: One of --test-id, --run-id, or --case-id must be provided.") + raise SystemExit(1) + + # Validate case-id requires run-id + if case_id is not None and run_id is None: + environment.elog("Error: --case-id requires --run-id to be specified.") + raise SystemExit(1) + + # Validate test-id is mutually exclusive with run-id and case-id + if test_id is not None and (run_id is not None or case_id is not None): + environment.elog("Error: --test-id cannot be used with --run-id or --case-id.") + raise SystemExit(1) + + # Validate run-id with case-id vs run-id alone + if run_id is not None and case_id is not None and test_id is not None: + environment.elog("Error: --test-id, --run-id, and --case-id cannot be used together.") + raise SystemExit(1) + + # Create API client + api_client = APIClient( + host_name=environment.host, + verbose_logging_function=environment.vlog, + logging_function=environment.log, + verify=environment.verify, + timeout=environment.timeout, + ) + + # Set credentials + api_client.username = environment.username + api_client.password = environment.password + api_client.api_key = environment.key + + # Create ResultHandler + result_handler = ResultHandler( + client=api_client, + environment=environment, + data_provider=None, + get_all_tests_in_run_callback=None, + handle_futures_callback=None, + ) + + # Retrieve results based on filter type + if test_id is not None: + environment.log(f"Retrieving results for test ID {test_id}...") + results_data, error_message = result_handler.get_results(test_id, offset, limit) + elif case_id is not None: # case_id with run_id (validated above) + environment.log(f"Retrieving results for case ID {case_id} in run ID {run_id}...") + results_data, error_message = result_handler.get_results_for_case(run_id, case_id, offset, limit) + else: # run_id alone + environment.log(f"Retrieving results for run ID {run_id}...") + results_data, error_message = result_handler.get_results_for_run(run_id, offset, limit) + + if error_message: + environment.elog(f"Error: Failed to retrieve results: {error_message}") + raise SystemExit(1) + + # Handle output format + if json_output: + # Output raw API JSON response + print(json.dumps(results_data, separators=(",", ":"))) + else: + # Display results line by line with details + results = results_data if isinstance(results_data, builtins.list) else [] + + if not results: + environment.log("No results found.") + else: + environment.log(f"Found {len(results)} result(s) (showing {offset + 1}-{offset + len(results)}):") + environment.log("") + + for result in results: + if show_all_fields: + # Show all fields from API response + environment.log(f" Result ID: {result.get('id', 'N/A')}") + + # Iterate through all fields in the result + for key, value in result.items(): + if key == "id": + continue # Already displayed as Result ID + + # Format field name for display (remove underscores, title case) + display_name = key.replace("_", " ").title() + + # Handle None values + if value is None: + display_value = "N/A" + elif isinstance(value, builtins.list): + # Handle list fields (like attachment_ids) + if value: + display_value = f"{len(value)} item(s): {value}" + else: + display_value = "[]" + else: + display_value = value + + environment.log(f" {display_name}: {display_value}") + + environment.log("") + else: + # Display standard fields with compact format + environment.log(f" Result ID: {result.get('id', 'N/A')}") + environment.log(f" Test ID: {result.get('test_id', 'N/A')}") + environment.log(f" Status ID: {result.get('status_id', 'N/A')}") + environment.log(f" Created On: {result.get('created_on', 'N/A')}") + environment.log(f" Created By: {result.get('created_by', 'N/A')}") + + if result.get("assignedto_id"): + environment.log(f" Assigned To: {result.get('assignedto_id')}") + + if result.get("comment"): + # Truncate long comments for readability + comment = result.get("comment", "") + if len(comment) > 100: + comment = comment[:100] + "..." + environment.log(f" Comment: {comment}") + + if result.get("version"): + environment.log(f" Version: {result.get('version')}") + + if result.get("elapsed"): + environment.log(f" Elapsed: {result.get('elapsed')}") + + if result.get("defects"): + environment.log(f" Defects: {result.get('defects')}") + + # Show custom fields if present + custom_fields = {k: v for k, v in result.items() if k.startswith("custom_")} + if custom_fields: + environment.log(f" Custom Fields: {len(custom_fields)} field(s)") + + environment.log("") + + +@cli.command() +@click.option( + "--result-id", + type=click.IntRange(min=1), + required=True, + metavar="", + help="ID of the test result to edit.", +) +@click.option( + "--status-id", + type=click.IntRange(min=1, max=12), + metavar="", + help="Test status ID to set (1=Passed, 2=Blocked, 3=Untested, 4=Retest, 5=Failed).", +) +@click.option( + "--comment", + metavar="", + help="Comment/notes to add or update for the result.", +) +@click.option( + "--version", + metavar="", + help="Version or build tested against.", +) +@click.option( + "--elapsed", + metavar="", + help="Time elapsed for the test (e.g., '1m 5s' or '65s').", +) +@click.option( + "--defects", + metavar="", + help="Comma-separated list of defect/bug IDs.", +) +@click.option( + "--assignedto-id", + type=click.IntRange(min=1), + metavar="", + help="User ID to assign the test result to.", +) +@click.option( + "--custom-fields", + metavar="", + help='Custom field values in JSON format (e.g., \'{"custom_field1": "value1"}\').', +) +@click.pass_context +@pass_environment +def update( + environment: Environment, + context: click.Context, + result_id: int, + status_id: int, + comment: str, + version: str, + elapsed: str, + defects: str, + assignedto_id: int, + custom_fields: str, + *args, + **kwargs, +): + """ + Update an existing test result in TestRail. + + This command allows you to update fields of an existing test result, such as status, + comment, elapsed time, defects, version, and custom fields. + + Example: + trcli results update --result-id 12345 --status-id 5 --comment "Test failed due to timeout" + """ + print_config(environment, "Update") + + environment.log("Updating test result...") + + # Validate that at least one field is provided + if not any([status_id, comment, version, elapsed, defects, assignedto_id, custom_fields]): + environment.elog("Error: At least one field must be provided to update.") + raise SystemExit(1) + + # Parse custom fields if provided + custom_fields_dict = None + if custom_fields: + try: + custom_fields_dict = json.loads(custom_fields) + if not isinstance(custom_fields_dict, dict): + environment.elog("Error: --custom-fields must be a valid JSON object.") + raise SystemExit(1) + except json.JSONDecodeError as e: + environment.elog(f"Error: Invalid JSON format for --custom-fields: {e}") + raise SystemExit(1) + + # Create API client + api_client = APIClient( + host_name=environment.host, + verbose_logging_function=environment.vlog, + logging_function=environment.log, + verify=environment.verify, + timeout=environment.timeout, + ) + + # Set credentials + api_client.username = environment.username + api_client.password = environment.password + api_client.api_key = environment.key + + # Create ResultHandler (minimal - only need client for edit_result) + result_handler = ResultHandler( + client=api_client, + environment=environment, + data_provider=None, + get_all_tests_in_run_callback=None, + handle_futures_callback=None, + ) + + # Print configuration + environment.log( + f"Update Result Parameters" + f"\n> Result ID: {result_id}" + + (f"\n> Status ID: {status_id}" if status_id else "") + + ( + f"\n> Comment: {comment[:50]}..." + if comment and len(comment) > 50 + else f"\n> Comment: {comment}" if comment else "" + ) + + (f"\n> Version: {version}" if version else "") + + (f"\n> Elapsed: {elapsed}" if elapsed else "") + + (f"\n> Defects: {defects}" if defects else "") + + (f"\n> Assigned To ID: {assignedto_id}" if assignedto_id else "") + + (f"\n> Custom Fields: {custom_fields_dict}" if custom_fields_dict else "") + ) + + # Edit the result + success, error_message = result_handler.edit_result( + result_id=result_id, + status_id=status_id, + comment=comment, + version=version, + elapsed=elapsed, + defects=defects, + assignedto_id=assignedto_id, + custom_fields=custom_fields_dict, + ) + + if success: + environment.log(f"✓ Result {result_id} updated successfully.") + else: + environment.elog(f"Error: Failed to update result {result_id}: {error_message}") + raise SystemExit(1) From 06c9e9b4d54c0019f00f778612070f1766d6423e Mon Sep 17 00:00:00 2001 From: acuanico-tr-galt Date: Tue, 26 May 2026 15:15:03 +0800 Subject: [PATCH 5/5] TRCLI-262: Updated unit tests for new results list and update command, also updated README with guides --- README.md | 71 +++++++ tests/test_api_request_handler.py | 337 ++++++++++++++++++++++++++++++ 2 files changed, 408 insertions(+) diff --git a/README.md b/README.md index 02895202..87d825a5 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,7 @@ Supported and loaded modules: - parse_openapi: OpenAPI YML Files - add_run: Create a new test run - labels: Manage labels (add, update, delete, list) + - results: Manage test results (list, update) - references: Manage references (cases and runs) ``` @@ -92,6 +93,7 @@ Commands: export_gherkin Export BDD test case from TestRail as .feature file import_gherkin Upload Gherkin .feature file to TestRail labels Manage labels in TestRail + results Manage test results in TestRail parse_cucumber Parse Cucumber JSON results and upload to TestRail parse_junit Parse JUnit report and upload results to TestRail parse_openapi Parse OpenAPI spec and create cases in TestRail @@ -1357,6 +1359,75 @@ tests are run across parallel, independent test nodes, all nodes should report t First, use the `add_run` command to create a new run; then, pass the run title and id to each of the test nodes, which will be used to upload all results into the same test run. +#### Managing Test Results + +The `results` command provides comprehensive test result management capabilities with two subcommands: `list` and `update`. + +##### Listing Test Results + +Retrieve test results from TestRail with flexible filtering options: + +```bash +# List results for a specific test +trcli -c myconfig.yml results list --test-id 1001 + +# List all results for a run +trcli -c myconfig.ymlresults list --run-id 100 + +# List results for a specific case within a run +trcli -c myconfig.ymlresults list --case-id 200 --run-id 100 + +# Use pagination +trcli -c myconfig.yml results list --test-id 1001 --offset 10 --limit 50 + +# Output as JSON for processing +trcli results list --test-id 1001 --json-output + +# Show all fields including custom fields in detail +trcli results list --run-id 100 --show-all-fields +``` + +**Filtering options:** +- `--test-id`: Get results for a specific test (mutually exclusive with other filters) +- `--run-id`: Get results for all tests in a run (can be used alone or with --case-id) +- `--case-id`: Get results for a specific case (requires --run-id) +- `--offset`: Pagination offset (default: 0) +- `--limit`: Pagination limit (default: 250) +- `--json-output`: Output raw JSON response from API +- `--show-all-fields`: Show all fields including custom fields in detail + +##### Updating Test Results + +Update existing test results after they have been created. Useful for re-run scenarios, adding comments, linking defects, or correcting result data: + +```bash +# Update test result status and add comment +trcli -c myconfig.yml results update --result-id 12345 --status-id 5 --comment "Test failed due to timeout" + +# Update multiple fields at once +trcli -c myconfig.yml results update --result-id 12345 \ + --status-id 1 \ + --comment "Passed after retry" \ + --elapsed "45s" \ + --defects "BUG-456" \ + --version "2.1.0" + +# Update custom fields (JSON format) +trcli -c myconfig.yml results update --result-id 12345 \ + --custom-fields '{"custom_test_environment": "Production", "custom_browser": "Chrome"}' + +# Assign result to a user +trcli -c myconfig.yml results update --result-id 12345 --assignedto-id 7 --comment "Needs investigation" +``` + +**Status IDs:** 1=Passed, 2=Blocked, 3=Untested, 4=Retest, 5=Failed + +For complete documentation: +```bash +trcli results list --help +trcli results update --help +``` + #### Labels Management The TestRail CLI provides comprehensive label management capabilities using the `labels` command. Labels help categorize and organize your test management assets efficiently, making it easier to filter and manage test cases, runs, and projects. diff --git a/tests/test_api_request_handler.py b/tests/test_api_request_handler.py index b63f95e3..534fe680 100644 --- a/tests/test_api_request_handler.py +++ b/tests/test_api_request_handler.py @@ -1568,3 +1568,340 @@ def test_cache_stats(self, api_request_handler: ApiRequestHandler, requests_mock assert stats["miss_count"] == 1 assert stats["hit_count"] == 1 assert stats["hit_rate"] == 50.0 # 1 hit out of 2 total requests + + def test_edit_result_success(self, api_request_handler: ApiRequestHandler, requests_mock): + """Test successfully editing a result with all fields""" + result_id = 12345 + mocked_response = { + "id": result_id, + "status_id": 5, + "comment": "Test failed due to timeout", + "version": "2.0.1", + "elapsed": "30s", + "defects": "BUG-123,BUG-456", + "assignedto_id": 7, + "custom_field1": "custom_value", + } + + requests_mock.post(create_url(f"edit_result/{result_id}"), json=mocked_response) + + success, error = api_request_handler.edit_result( + result_id=result_id, + status_id=5, + comment="Test failed due to timeout", + version="2.0.1", + elapsed="30s", + defects="BUG-123,BUG-456", + assignedto_id=7, + custom_fields={"custom_field1": "custom_value"}, + ) + + assert success is True + assert error is None + assert requests_mock.call_count == 1 + + # Verify request body + request_body = requests_mock.last_request.json() + assert request_body["status_id"] == 5 + assert request_body["comment"] == "Test failed due to timeout" + assert request_body["version"] == "2.0.1" + assert request_body["elapsed"] == "30s" + assert request_body["defects"] == "BUG-123,BUG-456" + assert request_body["assignedto_id"] == 7 + assert request_body["custom_field1"] == "custom_value" + + def test_edit_result_partial_fields(self, api_request_handler: ApiRequestHandler, requests_mock): + """Test editing a result with only some fields""" + result_id = 12345 + mocked_response = { + "id": result_id, + "status_id": 1, + "comment": "Test passed after retry", + } + + requests_mock.post(create_url(f"edit_result/{result_id}"), json=mocked_response) + + success, error = api_request_handler.edit_result( + result_id=result_id, + status_id=1, + comment="Test passed after retry", + ) + + assert success is True + assert error is None + + # Verify only provided fields are in request + request_body = requests_mock.last_request.json() + assert request_body["status_id"] == 1 + assert request_body["comment"] == "Test passed after retry" + assert "version" not in request_body + assert "elapsed" not in request_body + assert "defects" not in request_body + + def test_edit_result_api_error(self, api_request_handler: ApiRequestHandler, requests_mock): + """Test edit_result when API returns an error""" + result_id = 12345 + error_message = "Field :status_id is not a valid status." + + requests_mock.post( + create_url(f"edit_result/{result_id}"), + json={"error": error_message}, + status_code=400, + ) + + success, error = api_request_handler.edit_result( + result_id=result_id, + status_id=999, # Invalid status ID + ) + + assert success is False + assert error_message in error + + def test_edit_result_no_fields_provided(self, api_request_handler: ApiRequestHandler): + """Test edit_result when no fields are provided""" + result_id = 12345 + + success, error = api_request_handler.edit_result(result_id=result_id) + + assert success is False + assert error == "No fields provided to update" + + def test_edit_result_custom_fields_only(self, api_request_handler: ApiRequestHandler, requests_mock): + """Test editing a result with only custom fields""" + result_id = 12345 + mocked_response = { + "id": result_id, + "custom_automation_type": "Automated", + "custom_test_environment": "Production", + } + + requests_mock.post(create_url(f"edit_result/{result_id}"), json=mocked_response) + + success, error = api_request_handler.edit_result( + result_id=result_id, + custom_fields={ + "custom_automation_type": "Automated", + "custom_test_environment": "Production", + }, + ) + + assert success is True + assert error is None + + # Verify custom fields are in request + request_body = requests_mock.last_request.json() + assert request_body["custom_automation_type"] == "Automated" + assert request_body["custom_test_environment"] == "Production" + + def test_get_results_success(self, api_request_handler: ApiRequestHandler, requests_mock): + """Test successfully retrieving results for a test""" + test_id = 1001 + mocked_response = { + "offset": 0, + "limit": 250, + "size": 2, + "_links": {"next": None, "prev": None}, + "results": [ + { + "id": 1, + "test_id": test_id, + "status_id": 1, + "created_on": 1234567890, + "created_by": 1, + "comment": "Test passed", + }, + { + "id": 2, + "test_id": test_id, + "status_id": 5, + "created_on": 1234567900, + "created_by": 2, + "comment": "Test failed", + }, + ], + } + + requests_mock.get(create_url(f"get_results/{test_id}&offset=0&limit=250"), json=mocked_response) + + results, error = api_request_handler.get_results(test_id, offset=0, limit=250) + + assert error is None + assert len(results) == 2 + assert results[0]["id"] == 1 + assert results[0]["status_id"] == 1 + assert results[1]["id"] == 2 + assert results[1]["status_id"] == 5 + + def test_get_results_with_pagination(self, api_request_handler: ApiRequestHandler, requests_mock): + """Test retrieving results with custom pagination""" + test_id = 1001 + mocked_response = { + "offset": 10, + "limit": 5, + "size": 1, + "_links": {"next": None, "prev": None}, + "results": [ + { + "id": 11, + "test_id": test_id, + "status_id": 1, + "created_on": 1234567890, + "created_by": 1, + }, + ], + } + + requests_mock.get(create_url(f"get_results/{test_id}&offset=10&limit=5"), json=mocked_response) + + results, error = api_request_handler.get_results(test_id, offset=10, limit=5) + + assert error is None + assert len(results) == 1 + assert results[0]["id"] == 11 + + def test_get_results_api_error(self, api_request_handler: ApiRequestHandler, requests_mock): + """Test get_results when API returns an error""" + test_id = 1001 + error_message = "Field :test_id is not a valid test." + + requests_mock.get( + create_url(f"get_results/{test_id}&offset=0&limit=250"), + json={"error": error_message}, + status_code=400, + ) + + results, error = api_request_handler.get_results(test_id) + + assert len(results) == 0 + assert error_message in error + + def test_get_results_for_case_success(self, api_request_handler: ApiRequestHandler, requests_mock): + """Test successfully retrieving results for a case in a run""" + run_id = 100 + case_id = 200 + mocked_response = { + "offset": 0, + "limit": 250, + "size": 1, + "_links": {"next": None, "prev": None}, + "results": [ + { + "id": 1, + "test_id": 5001, + "status_id": 1, + "created_on": 1234567890, + "created_by": 1, + "comment": "Test passed", + }, + ], + } + + requests_mock.get( + create_url(f"get_results_for_case/{run_id}/{case_id}&offset=0&limit=250"), json=mocked_response + ) + + results, error = api_request_handler.get_results_for_case(run_id, case_id, offset=0, limit=250) + + assert error is None + assert len(results) == 1 + assert results[0]["id"] == 1 + assert results[0]["test_id"] == 5001 + + def test_get_results_for_case_api_error(self, api_request_handler: ApiRequestHandler, requests_mock): + """Test get_results_for_case when API returns an error""" + run_id = 100 + case_id = 200 + error_message = "Field :case_id is not a valid case." + + requests_mock.get( + create_url(f"get_results_for_case/{run_id}/{case_id}&offset=0&limit=250"), + json={"error": error_message}, + status_code=400, + ) + + results, error = api_request_handler.get_results_for_case(run_id, case_id) + + assert len(results) == 0 + assert error_message in error + + def test_get_results_for_run_success(self, api_request_handler: ApiRequestHandler, requests_mock): + """Test successfully retrieving results for all tests in a run""" + run_id = 100 + mocked_response = { + "offset": 0, + "limit": 250, + "size": 2, + "_links": {"next": None, "prev": None}, + "results": [ + { + "id": 1, + "test_id": 5001, + "status_id": 1, + "created_on": 1234567890, + "created_by": 1, + "comment": "Test passed", + }, + { + "id": 2, + "test_id": 5002, + "status_id": 5, + "created_on": 1234567900, + "created_by": 2, + "comment": "Test failed", + }, + ], + } + + requests_mock.get(create_url(f"get_results_for_run/{run_id}&offset=0&limit=250"), json=mocked_response) + + results, error = api_request_handler.get_results_for_run(run_id, offset=0, limit=250) + + assert error is None + assert len(results) == 2 + assert results[0]["id"] == 1 + assert results[0]["test_id"] == 5001 + assert results[1]["id"] == 2 + assert results[1]["test_id"] == 5002 + + def test_get_results_for_run_with_pagination(self, api_request_handler: ApiRequestHandler, requests_mock): + """Test retrieving run results with custom pagination""" + run_id = 100 + mocked_response = { + "offset": 10, + "limit": 5, + "size": 1, + "_links": {"next": None, "prev": None}, + "results": [ + { + "id": 11, + "test_id": 5011, + "status_id": 1, + "created_on": 1234567890, + "created_by": 1, + }, + ], + } + + requests_mock.get(create_url(f"get_results_for_run/{run_id}&offset=10&limit=5"), json=mocked_response) + + results, error = api_request_handler.get_results_for_run(run_id, offset=10, limit=5) + + assert error is None + assert len(results) == 1 + assert results[0]["id"] == 11 + + def test_get_results_for_run_api_error(self, api_request_handler: ApiRequestHandler, requests_mock): + """Test get_results_for_run when API returns an error""" + run_id = 100 + error_message = "Field :run_id is not a valid run." + + requests_mock.get( + create_url(f"get_results_for_run/{run_id}&offset=0&limit=250"), + json={"error": error_message}, + status_code=400, + ) + + results, error = api_request_handler.get_results_for_run(run_id) + + assert len(results) == 0 + assert error_message in error