From ce0686056ab4c45c4991a7cba553806926e1d0af Mon Sep 17 00:00:00 2001 From: Romulo Quidute Filho Date: Tue, 8 Sep 2026 14:03:25 -0300 Subject: [PATCH 01/10] [Feature] Add test-run-execution repeat, export, and import CLI commands (#1104) Adds three new `th-cli test-run-execution` subcommands wired to already-existing backend endpoints, mirroring the UX of the existing `pics-export` (#1092) and `project export`/`project import` commands: - `repeat --id [--title ] [--start]`: creates a new execution with the same selected tests/config as an existing one. 404s surface as a clear "not found" error. `--start` additionally starts the repeated execution (fire-and-forget, unlike `run-tests` it does not stream live progress); a 409 (e.g. engine busy) makes clear the execution was still created even though it couldn't start. - `export --id <ID> [--output-file <FILE>]`: writes the execution's exported JSON to a file, defaulting the filename to the execution's title. - `import --file <FILE> --project-id <ID>`: posts a previously exported JSON file to the import endpoint. A `db_revision` mismatch (422) surfaces as a clear CLI error instead of a raw API dump. No backend changes required; both endpoints and API client methods already existed. --- README.md | 22 + ...test_run_execution_repeat_export_import.py | 409 ++++++++++++++++++ th_cli/commands/test_run_execution.py | 197 ++++++++- 3 files changed, 626 insertions(+), 2 deletions(-) create mode 100644 tests/test_test_run_execution_repeat_export_import.py diff --git a/README.md b/README.md index ac7f1df..462fe27 100644 --- a/README.md +++ b/README.md @@ -145,6 +145,28 @@ Run `th-cli test-run-execution --id {id}` with a test run execution id to fetch For JSON respond, add `--json` to the command. +### Test Run Execution Repeat + +Run `th-cli test-run-execution repeat --id {id}` to create a new test run execution with +the same selected tests and config as an existing one. Use `--title` to override the +generated title (defaults to the original title with an updated timestamp; the backend +always appends a timestamp regardless). Add `--start` to start the repeated execution +right away without waiting for it to complete (unlike `run-tests`, it does not stream +live progress). + +### Test Run Execution Export + +Run `th-cli test-run-execution export --id {id}` to export a test run execution's config +and results to a JSON file. Use `--output-file` to override the default filename +(`<execution-title>-execution.json`). + +### Test Run Execution Import + +Run `th-cli test-run-execution import --file {file} --project-id {id}` to import a test +run execution previously written by `test-run-execution export` into the given project. +The backend rejects the import if the file's `db_revision` doesn't match the destination +instance's current database revision. + ### Project Create Run `th-cli project create --name {project name} --config {config file}` to create a new project. Project name is required. diff --git a/tests/test_test_run_execution_repeat_export_import.py b/tests/test_test_run_execution_repeat_export_import.py new file mode 100644 index 0000000..aab9a3a --- /dev/null +++ b/tests/test_test_run_execution_repeat_export_import.py @@ -0,0 +1,409 @@ +# +# Copyright (c) 2026 Project CHIP Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +"""Tests for the `test-run-execution repeat/export/import` commands.""" + +import json +from pathlib import Path +from unittest.mock import Mock, patch + +import pytest +from click.testing import CliRunner + +from th_cli.api_lib_autogen import models as api_models +from th_cli.api_lib_autogen.exceptions import UnexpectedResponse +from th_cli.commands.test_run_execution import test_run_execution +from th_cli.exceptions import ConfigurationError + + +def _make_exported_execution(title: str = "My Execution!") -> api_models.ExportedTestRunExecution: + """Build a minimal ExportedTestRunExecution for export/import round-trip tests.""" + from datetime import datetime, timezone + + return api_models.ExportedTestRunExecution( + db_revision="abc123", + test_run_execution=api_models.TestRunExecutionToExport( + title=title, + state=api_models.TestStateEnum.passed, + created_at=datetime.now(timezone.utc), + log=[], + ), + ) + + +@pytest.mark.unit +@pytest.mark.cli +class TestRepeatCommand: + """Test cases for the `test-run-execution repeat` command.""" + + def test_repeat_success( + self, + cli_runner: CliRunner, + mock_sync_apis: Mock, + sample_test_run_execution: api_models.TestRunExecutionWithChildren, + ) -> None: + """A successful repeat reports the new execution's ID and title.""" + api = mock_sync_apis.test_run_executions_api + api.repeat_test_run_execution_api_v1_test_run_executions__id__repeat_post.return_value = ( + sample_test_run_execution + ) + + with patch("th_cli.commands.test_run_execution.SyncApis", return_value=mock_sync_apis): + result = cli_runner.invoke(test_run_execution, ["repeat", "--id", "1"]) + + assert result.exit_code == 0 + assert f"repeated as new execution {sample_test_run_execution.id}" in result.output + assert sample_test_run_execution.title in result.output + api.repeat_test_run_execution_api_v1_test_run_executions__id__repeat_post.assert_called_once_with( + id=1, title=None + ) + + def test_repeat_with_custom_title( + self, + cli_runner: CliRunner, + mock_sync_apis: Mock, + sample_test_run_execution: api_models.TestRunExecutionWithChildren, + ) -> None: + """--title is forwarded to the API call.""" + api = mock_sync_apis.test_run_executions_api + api.repeat_test_run_execution_api_v1_test_run_executions__id__repeat_post.return_value = ( + sample_test_run_execution + ) + + with patch("th_cli.commands.test_run_execution.SyncApis", return_value=mock_sync_apis): + result = cli_runner.invoke(test_run_execution, ["repeat", "--id", "1", "--title", "Custom Title"]) + + assert result.exit_code == 0 + api.repeat_test_run_execution_api_v1_test_run_executions__id__repeat_post.assert_called_once_with( + id=1, title="Custom Title" + ) + + def test_repeat_with_start( + self, + cli_runner: CliRunner, + mock_sync_apis: Mock, + sample_test_run_execution: api_models.TestRunExecutionWithChildren, + ) -> None: + """--start also calls the start endpoint for the newly created execution.""" + api = mock_sync_apis.test_run_executions_api + api.repeat_test_run_execution_api_v1_test_run_executions__id__repeat_post.return_value = ( + sample_test_run_execution + ) + api.start_test_run_execution_api_v1_test_run_executions__id__start_post.return_value = ( + sample_test_run_execution + ) + + with patch("th_cli.commands.test_run_execution.SyncApis", return_value=mock_sync_apis): + result = cli_runner.invoke(test_run_execution, ["repeat", "--id", "1", "--start"]) + + assert result.exit_code == 0 + assert f"Test run execution {sample_test_run_execution.id} started." in result.output + api.start_test_run_execution_api_v1_test_run_executions__id__start_post.assert_called_once_with( + id=sample_test_run_execution.id + ) + + def test_repeat_without_start_does_not_start( + self, + cli_runner: CliRunner, + mock_sync_apis: Mock, + sample_test_run_execution: api_models.TestRunExecutionWithChildren, + ) -> None: + """Without --start, the repeated execution is created but not started.""" + api = mock_sync_apis.test_run_executions_api + api.repeat_test_run_execution_api_v1_test_run_executions__id__repeat_post.return_value = ( + sample_test_run_execution + ) + + with patch("th_cli.commands.test_run_execution.SyncApis", return_value=mock_sync_apis): + result = cli_runner.invoke(test_run_execution, ["repeat", "--id", "1"]) + + assert result.exit_code == 0 + api.start_test_run_execution_api_v1_test_run_executions__id__start_post.assert_not_called() + + def test_repeat_with_start_api_error( + self, + cli_runner: CliRunner, + mock_sync_apis: Mock, + sample_test_run_execution: api_models.TestRunExecutionWithChildren, + ) -> None: + """A failure to start the repeated execution is surfaced via the standard error-handling path.""" + api = mock_sync_apis.test_run_executions_api + api.repeat_test_run_execution_api_v1_test_run_executions__id__repeat_post.return_value = ( + sample_test_run_execution + ) + api.start_test_run_execution_api_v1_test_run_executions__id__start_post.side_effect = UnexpectedResponse( + status_code=500, content=b"Internal Server Error" + ) + + with patch("th_cli.commands.test_run_execution.SyncApis", return_value=mock_sync_apis): + result = cli_runner.invoke(test_run_execution, ["repeat", "--id", "1", "--start"]) + + assert result.exit_code == 1 + assert ( + f"Failed to start repeated test run execution '{sample_test_run_execution.id}' " + "(Status: 500) - Internal Server Error" in result.output + ) + + def test_repeat_with_start_conflict( + self, + cli_runner: CliRunner, + mock_sync_apis: Mock, + sample_test_run_execution: api_models.TestRunExecutionWithChildren, + ) -> None: + """A 409 (e.g. test engine busy) makes clear the execution was still created.""" + api = mock_sync_apis.test_run_executions_api + api.repeat_test_run_execution_api_v1_test_run_executions__id__repeat_post.return_value = ( + sample_test_run_execution + ) + api.start_test_run_execution_api_v1_test_run_executions__id__start_post.side_effect = UnexpectedResponse( + status_code=409, content={"detail": "Test Engine is busy."} + ) + + with patch("th_cli.commands.test_run_execution.SyncApis", return_value=mock_sync_apis): + result = cli_runner.invoke(test_run_execution, ["repeat", "--id", "1", "--start"]) + + assert result.exit_code == 1 + assert f"Execution {sample_test_run_execution.id} was created but could not be started" in result.output + assert "Test Engine is busy." in result.output + + def test_repeat_not_found(self, cli_runner: CliRunner, mock_sync_apis: Mock) -> None: + """A 404 from the API is surfaced as a clear 'not found' error.""" + api = mock_sync_apis.test_run_executions_api + api.repeat_test_run_execution_api_v1_test_run_executions__id__repeat_post.side_effect = UnexpectedResponse( + status_code=404, content={"detail": "TestRunExecution not found"} + ) + + with patch("th_cli.commands.test_run_execution.SyncApis", return_value=mock_sync_apis): + result = cli_runner.invoke(test_run_execution, ["repeat", "--id", "999"]) + + assert result.exit_code == 1 + assert "Test run execution with ID '999' not found." in result.output + + def test_repeat_not_found_does_not_start(self, cli_runner: CliRunner, mock_sync_apis: Mock) -> None: + """A failed repeat (404) must not attempt to start anything, even with --start.""" + api = mock_sync_apis.test_run_executions_api + api.repeat_test_run_execution_api_v1_test_run_executions__id__repeat_post.side_effect = UnexpectedResponse( + status_code=404, content={"detail": "TestRunExecution not found"} + ) + + with patch("th_cli.commands.test_run_execution.SyncApis", return_value=mock_sync_apis): + result = cli_runner.invoke(test_run_execution, ["repeat", "--id", "999", "--start"]) + + assert result.exit_code == 1 + api.start_test_run_execution_api_v1_test_run_executions__id__start_post.assert_not_called() + + def test_repeat_other_api_error(self, cli_runner: CliRunner, mock_sync_apis: Mock) -> None: + """A non-404 API error is surfaced via the standard error-handling path.""" + api = mock_sync_apis.test_run_executions_api + api.repeat_test_run_execution_api_v1_test_run_executions__id__repeat_post.side_effect = UnexpectedResponse( + status_code=500, content=b"Internal Server Error" + ) + + with patch("th_cli.commands.test_run_execution.SyncApis", return_value=mock_sync_apis): + result = cli_runner.invoke(test_run_execution, ["repeat", "--id", "1"]) + + assert result.exit_code == 1 + assert "Failed to repeat test run execution '1' (Status: 500) - Internal Server Error" in result.output + + def test_repeat_other_api_error_does_not_start(self, cli_runner: CliRunner, mock_sync_apis: Mock) -> None: + """A failed repeat (non-404) must not attempt to start anything, even with --start.""" + api = mock_sync_apis.test_run_executions_api + api.repeat_test_run_execution_api_v1_test_run_executions__id__repeat_post.side_effect = UnexpectedResponse( + status_code=500, content=b"Internal Server Error" + ) + + with patch("th_cli.commands.test_run_execution.SyncApis", return_value=mock_sync_apis): + result = cli_runner.invoke(test_run_execution, ["repeat", "--id", "1", "--start"]) + + assert result.exit_code == 1 + api.start_test_run_execution_api_v1_test_run_executions__id__start_post.assert_not_called() + + def test_repeat_configuration_error(self, cli_runner: CliRunner) -> None: + """A ConfigurationError from get_client is surfaced to the user.""" + with patch( + "th_cli.commands.test_run_execution.get_client", + side_effect=ConfigurationError("Could not connect to server"), + ): + result = cli_runner.invoke(test_run_execution, ["repeat", "--id", "1"]) + + assert result.exit_code == 1 + assert "Error: Could not connect to server" in result.output + + def test_repeat_requires_id(self, cli_runner: CliRunner) -> None: + """The --id parameter is required.""" + result = cli_runner.invoke(test_run_execution, ["repeat"]) + + assert result.exit_code != 0 + assert "Missing option" in result.output or "--id" in result.output + + def test_repeat_help_message(self, cli_runner: CliRunner) -> None: + """Test the help message for the repeat command.""" + result = cli_runner.invoke(test_run_execution, ["repeat", "--help"]) + + assert result.exit_code == 0 + assert "--id" in result.output + assert "--title" in result.output + assert "--start" in result.output + + +@pytest.mark.unit +@pytest.mark.cli +class TestExportExecutionCommand: + """Test cases for the `test-run-execution export` command.""" + + def test_export_success_default_filename(self, cli_runner: CliRunner, mock_sync_apis: Mock) -> None: + """When no --output-file is given, the file is named after the execution title.""" + exported = _make_exported_execution(title="My Execution!") + api = mock_sync_apis.test_run_executions_api + api.export_test_run_execution_api_v1_test_run_executions__id__export_get.return_value = exported + + with cli_runner.isolated_filesystem(): + with patch("th_cli.commands.test_run_execution.SyncApis", return_value=mock_sync_apis): + result = cli_runner.invoke(test_run_execution, ["export", "--id", "1"]) + + assert result.exit_code == 0 + assert Path("MyExecution-execution.json").exists() + saved = json.loads(Path("MyExecution-execution.json").read_text()) + assert saved["test_run_execution"]["title"] == "My Execution!" + + api.export_test_run_execution_api_v1_test_run_executions__id__export_get.assert_called_once_with(id=1) + + def test_export_success_custom_filename(self, cli_runner: CliRunner, mock_sync_apis: Mock, temp_dir: Path) -> None: + """When --output-file is given, the export is written there.""" + exported = _make_exported_execution() + api = mock_sync_apis.test_run_executions_api + api.export_test_run_execution_api_v1_test_run_executions__id__export_get.return_value = exported + output_path = str(temp_dir / "run-42.json") + + with patch("th_cli.commands.test_run_execution.SyncApis", return_value=mock_sync_apis): + result = cli_runner.invoke(test_run_execution, ["export", "--id", "42", "--output-file", output_path]) + + assert result.exit_code == 0 + assert f"exported to '{output_path}'" in result.output + assert Path(output_path).exists() + + def test_export_not_found(self, cli_runner: CliRunner, mock_sync_apis: Mock) -> None: + """A 404 from the API is surfaced as a clear 'not found' error.""" + api = mock_sync_apis.test_run_executions_api + api.export_test_run_execution_api_v1_test_run_executions__id__export_get.side_effect = UnexpectedResponse( + status_code=404, content={"detail": "Test Run Execution with id 999 not found"} + ) + + with patch("th_cli.commands.test_run_execution.SyncApis", return_value=mock_sync_apis): + result = cli_runner.invoke(test_run_execution, ["export", "--id", "999"]) + + assert result.exit_code == 1 + assert "Test run execution with ID '999' not found." in result.output + + def test_export_write_failure_raises_cli_error(self, cli_runner: CliRunner, mock_sync_apis: Mock) -> None: + """A file-write failure after a successful export request is surfaced as a clean CLIError.""" + exported = _make_exported_execution() + api = mock_sync_apis.test_run_executions_api + api.export_test_run_execution_api_v1_test_run_executions__id__export_get.return_value = exported + + with patch("th_cli.commands.test_run_execution.SyncApis", return_value=mock_sync_apis): + with patch("pathlib.Path.write_text", side_effect=OSError("Permission denied")): + result = cli_runner.invoke( + test_run_execution, ["export", "--id", "1", "--output-file", "/no/such/dir/out.json"] + ) + + assert result.exit_code == 1 + assert "Failed to write export file '/no/such/dir/out.json'" in result.output + assert "Permission denied" in result.output + + def test_export_help_message(self, cli_runner: CliRunner) -> None: + """Test the help message for the export command.""" + result = cli_runner.invoke(test_run_execution, ["export", "--help"]) + + assert result.exit_code == 0 + assert "--id" in result.output + assert "--output-file" in result.output + + +@pytest.mark.unit +@pytest.mark.cli +class TestImportExecutionCommand: + """Test cases for the `test-run-execution import` command.""" + + def test_import_success( + self, + cli_runner: CliRunner, + mock_sync_apis: Mock, + sample_test_run_execution: api_models.TestRunExecutionWithChildren, + temp_dir: Path, + ) -> None: + """A successful import reports the new execution's ID and title.""" + exported = _make_exported_execution(title="Imported Run") + import_file = temp_dir / "run.json" + import_file.write_text(exported.model_dump_json()) + + api = mock_sync_apis.test_run_executions_api + api.import_test_run_execution_api_v1_test_run_executions_import_post.return_value = sample_test_run_execution + + with patch("th_cli.commands.test_run_execution.SyncApis", return_value=mock_sync_apis): + result = cli_runner.invoke(test_run_execution, ["import", "--file", str(import_file), "--project-id", "7"]) + + assert result.exit_code == 0 + assert f"imported as execution {sample_test_run_execution.id}" in result.output + call_args = api.import_test_run_execution_api_v1_test_run_executions_import_post.call_args + assert call_args.kwargs["project_id"] == 7 + assert call_args.kwargs["body"].import_file == import_file.read_bytes() + + def test_import_file_not_found(self, cli_runner: CliRunner, mock_sync_apis: Mock) -> None: + """A non-existent --file is rejected by Click's exists=True validation.""" + with patch("th_cli.commands.test_run_execution.SyncApis", return_value=mock_sync_apis): + result = cli_runner.invoke( + test_run_execution, ["import", "--file", "nonexistent.json", "--project-id", "1"] + ) + + assert result.exit_code == 2 + assert "does not exist" in result.output + + def test_import_db_revision_mismatch(self, cli_runner: CliRunner, mock_sync_apis: Mock, temp_dir: Path) -> None: + """A db_revision mismatch (422) is surfaced with the backend's plain-text detail.""" + exported = _make_exported_execution() + import_file = temp_dir / "run.json" + import_file.write_text(exported.model_dump_json()) + + api = mock_sync_apis.test_run_executions_api + api.import_test_run_execution_api_v1_test_run_executions_import_post.side_effect = UnexpectedResponse( + status_code=422, + content={"detail": "Mismatching 'db_revision'. Trying to import from abc123 to def456"}, + ) + + with patch("th_cli.commands.test_run_execution.SyncApis", return_value=mock_sync_apis): + result = cli_runner.invoke(test_run_execution, ["import", "--file", str(import_file), "--project-id", "1"]) + + assert result.exit_code == 1 + assert "Mismatching 'db_revision'" in result.output + assert "{" not in result.output + + def test_import_requires_project_id(self, cli_runner: CliRunner, temp_dir: Path) -> None: + """The --project-id parameter is required.""" + import_file = temp_dir / "run.json" + import_file.write_text(_make_exported_execution().model_dump_json()) + + result = cli_runner.invoke(test_run_execution, ["import", "--file", str(import_file)]) + + assert result.exit_code != 0 + assert "Missing option" in result.output or "--project-id" in result.output + + def test_import_help_message(self, cli_runner: CliRunner) -> None: + """Test the help message for the import command.""" + result = cli_runner.invoke(test_run_execution, ["import", "--help"]) + + assert result.exit_code == 0 + assert "--file" in result.output + assert "--project-id" in result.output diff --git a/th_cli/commands/test_run_execution.py b/th_cli/commands/test_run_execution.py index 8de203b..8e35b3a 100644 --- a/th_cli/commands/test_run_execution.py +++ b/th_cli/commands/test_run_execution.py @@ -14,14 +14,23 @@ # limitations under the License. # from contextlib import closing +from pathlib import Path import click from th_cli.api_lib_autogen.api_client import SyncApis from th_cli.api_lib_autogen.exceptions import UnexpectedResponse +from th_cli.api_lib_autogen.models import BodyImportTestRunExecutionApiV1TestRunExecutionsImportPost from th_cli.client import get_client -from th_cli.colorize import colorize_cmd_help, colorize_header, colorize_help, colorize_state, italic -from th_cli.exceptions import CLIError, handle_api_error +from th_cli.colorize import ( + colorize_cmd_help, + colorize_header, + colorize_help, + colorize_state, + colorize_success, + italic, +) +from th_cli.exceptions import CLIError, handle_api_error, handle_file_error from th_cli.utils import __print_json table_format_header = "{:<6} {:<55} {}" @@ -248,6 +257,102 @@ def pics_export(id: int, output_file: str) -> None: raise # Re-raise CLI Errors as-is +@test_run_execution.command( + name="repeat", + short_help=colorize_help("Repeat a test run execution"), + help=colorize_cmd_help("repeat", "Create a new execution with the same selected tests/config as an existing one"), +) +@click.option( + "--id", + "-i", + required=True, + type=int, + help=colorize_help("ID of the Test Run Execution to repeat"), +) +@click.option( + "--title", + "-n", + required=False, + type=str, + help=colorize_help( + "Title for the new execution. Defaults to the original title; the backend always " + "appends an updated timestamp regardless" + ), +) +@click.option( + "--start", + is_flag=True, + default=False, + help=colorize_help("Start the repeated execution right away (does not wait for completion or stream progress)"), +) +def repeat(id: int, title: str | None, start: bool) -> None: + try: + with closing(get_client()) as client: + sync_apis = SyncApis(client) + __repeat_test_run_execution(sync_apis, id, title, start) + + except CLIError: + raise # Re-raise CLI Errors as-is + + +@test_run_execution.command( + name="export", + short_help=colorize_help("Export a test run execution to a JSON file"), + help=colorize_cmd_help("export", "Export a test run execution's config and results to a JSON file"), +) +@click.option( + "--id", + "-i", + required=True, + type=int, + help=colorize_help("ID of the Test Run Execution to export"), +) +@click.option( + "--output-file", + "-o", + required=False, + type=click.Path(file_okay=True, dir_okay=False), + help=colorize_help("Output JSON file path (defaults to <execution-title>-execution.json)"), +) +def export(id: int, output_file: str | None) -> None: + try: + with closing(get_client()) as client: + sync_apis = SyncApis(client) + __export_test_run_execution(sync_apis, id, output_file) + + except CLIError: + raise # Re-raise CLI Errors as-is + + +@test_run_execution.command( + name="import", + short_help=colorize_help("Import a test run execution from a JSON file"), + help=colorize_cmd_help("import", "Import a test run execution previously exported with 'export'"), +) +@click.option( + "--file", + "-f", + required=True, + type=click.Path(file_okay=True, dir_okay=False, exists=True), + help=colorize_help("JSON file previously exported with 'test-run-execution export'"), +) +@click.option( + "--project-id", + "-p", + required=True, + type=int, + help=colorize_help("Project ID to import the execution into"), +) +def import_execution(file: str, project_id: int) -> None: + try: + with closing(get_client()) as client: + sync_apis = SyncApis(client) + __import_test_run_execution(sync_apis, file, project_id) + + except CLIError: + raise # Re-raise CLI Errors as-is + + def __test_run_execution_by_id(sync_apis: SyncApis, id: int, json: bool) -> None: try: test_run_execution_api = sync_apis.test_run_executions_api @@ -447,6 +552,94 @@ def __fetch_test_run_execution_pics_export(sync_apis: SyncApis, id: int, output_ handle_api_error(e, "fetch test run execution PICS export") +def _extract_error_detail(e: UnexpectedResponse) -> str: + """Pull a plain-text 'detail' message out of an UnexpectedResponse's content, if present.""" + content = e.content + if isinstance(content, bytes): + content = content.decode("utf-8", errors="ignore") + if isinstance(content, dict): + detail = content.get("detail") + if isinstance(detail, str): + return detail + return str(content) + + +def __repeat_test_run_execution(sync_apis: SyncApis, id: int, title: str | None, start: bool) -> None: + try: + test_run_execution_api = sync_apis.test_run_executions_api + new_execution = test_run_execution_api.repeat_test_run_execution_api_v1_test_run_executions__id__repeat_post( + id=id, title=title + ) + click.echo( + colorize_success(f"Test run execution {id} repeated as new execution {new_execution.id}") + + f" ('{new_execution.title}')" + ) + except UnexpectedResponse as e: + if e.status_code == 404: + raise CLIError(f"Test run execution with ID '{id}' not found.") + handle_api_error(e, f"repeat test run execution '{id}'") + + if start: + try: + test_run_execution_api.start_test_run_execution_api_v1_test_run_executions__id__start_post( + id=new_execution.id + ) + click.echo(colorize_success(f"Test run execution {new_execution.id} started.")) + except UnexpectedResponse as e: + if e.status_code == 409: + raise CLIError( + f"Execution {new_execution.id} was created but could not be started: " + f"{_extract_error_detail(e)}" + ) + handle_api_error(e, f"start repeated test run execution '{new_execution.id}'") + + +def __export_test_run_execution(sync_apis: SyncApis, id: int, output_file: str | None) -> None: + try: + test_run_execution_api = sync_apis.test_run_executions_api + exported = test_run_execution_api.export_test_run_execution_api_v1_test_run_executions__id__export_get(id=id) + except UnexpectedResponse as e: + if e.status_code == 404: + raise CLIError(f"Test run execution with ID '{id}' not found.") + handle_api_error(e, f"export test run execution '{id}'") + + if not output_file: + if exported.test_run_execution.title: + import re + + output_file = re.sub(r"[^\w]", "", exported.test_run_execution.title) + "-execution.json" + else: + output_file = f"test_run_execution_{id}_export.json" + + try: + Path(output_file).write_text(exported.model_dump_json(indent=2)) + click.echo(colorize_success(f"Test run execution {id} exported to '{output_file}'")) + except OSError as e: + raise CLIError(f"Failed to write export file '{output_file}': {e}") + + +def __import_test_run_execution(sync_apis: SyncApis, file: str, project_id: int) -> None: + try: + file_bytes = Path(file).read_bytes() + except FileNotFoundError as e: + handle_file_error(e, "import file") + except OSError as e: + raise CLIError(f"Failed to read import file '{file}': {e}") + + body = BodyImportTestRunExecutionApiV1TestRunExecutionsImportPost(import_file=file_bytes) + + try: + test_run_execution_api = sync_apis.test_run_executions_api + response = test_run_execution_api.import_test_run_execution_api_v1_test_run_executions_import_post( + body=body, project_id=project_id + ) + click.echo( + colorize_success(f"Test run execution imported as execution {response.id}") + f" ('{response.title}')" + ) + except UnexpectedResponse as e: + handle_api_error(e, f"import test run execution from '{file}'") + + def __print_table_test_executions(test_execution: list) -> None: __print_table_header() if isinstance(test_execution, list): From 6833301f92029a7ada9b8c62961cc0692440b104 Mon Sep 17 00:00:00 2001 From: Romulo Quidute Filho <rquidute@apple.com> Date: Wed, 9 Sep 2026 09:08:36 -0300 Subject: [PATCH 02/10] Pin UTF-8 encoding when writing exported test run execution JSON Path.write_text() otherwise uses the locale encoding, which can raise UnicodeEncodeError for non-ASCII execution titles/logs on non-UTF-8 locales (not caught by the existing except OSError handler). Matches the encoding already pinned for the log writer. Addresses CodeRabbit review comment on PR #119. --- th_cli/commands/test_run_execution.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/th_cli/commands/test_run_execution.py b/th_cli/commands/test_run_execution.py index 8e35b3a..10d68e6 100644 --- a/th_cli/commands/test_run_execution.py +++ b/th_cli/commands/test_run_execution.py @@ -612,7 +612,7 @@ def __export_test_run_execution(sync_apis: SyncApis, id: int, output_file: str | output_file = f"test_run_execution_{id}_export.json" try: - Path(output_file).write_text(exported.model_dump_json(indent=2)) + Path(output_file).write_text(exported.model_dump_json(indent=2), encoding="utf-8") click.echo(colorize_success(f"Test run execution {id} exported to '{output_file}'")) except OSError as e: raise CLIError(f"Failed to write export file '{output_file}': {e}") From 52bdf77351ef136efac1be6eabd6bc1f4ee7f4bf Mon Sep 17 00:00:00 2001 From: Romulo Quidute Filho <rquidute@apple.com> Date: Thu, 10 Sep 2026 14:54:13 -0300 Subject: [PATCH 03/10] Fix export/import timeout crashes and stream live progress for repeat --start - export/import/repeat now use a 120s (10s connect) client timeout and catch ResponseHandlingException, surfacing a clean error message instead of a raw httpx traceback on slow transfers. - repeat --start now attaches to the started execution via TestRunSocket the same way run-tests does, streaming live progress and forwarding user prompts, instead of just printing a static "started" message. --- README.md | 4 +- ...test_run_execution_repeat_export_import.py | 213 +++++++++++++++--- th_cli/commands/test_run_execution.py | 121 ++++++++-- 3 files changed, 277 insertions(+), 61 deletions(-) diff --git a/README.md b/README.md index 462fe27..129a3e0 100644 --- a/README.md +++ b/README.md @@ -151,8 +151,8 @@ Run `th-cli test-run-execution repeat --id {id}` to create a new test run execut the same selected tests and config as an existing one. Use `--title` to override the generated title (defaults to the original title with an updated timestamp; the backend always appends a timestamp regardless). Add `--start` to start the repeated execution -right away without waiting for it to complete (unlike `run-tests`, it does not stream -live progress). +and attach to it the same way `run-tests` does: streaming live test progress and +forwarding any user prompts to this terminal. ### Test Run Execution Export diff --git a/tests/test_test_run_execution_repeat_export_import.py b/tests/test_test_run_execution_repeat_export_import.py index aab9a3a..e855246 100644 --- a/tests/test_test_run_execution_repeat_export_import.py +++ b/tests/test_test_run_execution_repeat_export_import.py @@ -17,13 +17,14 @@ import json from pathlib import Path -from unittest.mock import Mock, patch +from unittest.mock import AsyncMock, Mock, patch import pytest from click.testing import CliRunner +from httpx import ReadTimeout from th_cli.api_lib_autogen import models as api_models -from th_cli.api_lib_autogen.exceptions import UnexpectedResponse +from th_cli.api_lib_autogen.exceptions import ResponseHandlingException, UnexpectedResponse from th_cli.commands.test_run_execution import test_run_execution from th_cli.exceptions import ConfigurationError @@ -51,16 +52,20 @@ class TestRepeatCommand: def test_repeat_success( self, cli_runner: CliRunner, - mock_sync_apis: Mock, + mock_async_apis: Mock, + mock_api_client: Mock, sample_test_run_execution: api_models.TestRunExecutionWithChildren, ) -> None: """A successful repeat reports the new execution's ID and title.""" - api = mock_sync_apis.test_run_executions_api + api = mock_async_apis.test_run_executions_api api.repeat_test_run_execution_api_v1_test_run_executions__id__repeat_post.return_value = ( sample_test_run_execution ) - with patch("th_cli.commands.test_run_execution.SyncApis", return_value=mock_sync_apis): + with ( + patch("th_cli.commands.test_run_execution.get_client", return_value=mock_api_client), + patch("th_cli.commands.test_run_execution.AsyncApis", return_value=mock_async_apis), + ): result = cli_runner.invoke(test_run_execution, ["repeat", "--id", "1"]) assert result.exit_code == 0 @@ -69,20 +74,25 @@ def test_repeat_success( api.repeat_test_run_execution_api_v1_test_run_executions__id__repeat_post.assert_called_once_with( id=1, title=None ) + mock_api_client.aclose.assert_called_once() def test_repeat_with_custom_title( self, cli_runner: CliRunner, - mock_sync_apis: Mock, + mock_async_apis: Mock, + mock_api_client: Mock, sample_test_run_execution: api_models.TestRunExecutionWithChildren, ) -> None: """--title is forwarded to the API call.""" - api = mock_sync_apis.test_run_executions_api + api = mock_async_apis.test_run_executions_api api.repeat_test_run_execution_api_v1_test_run_executions__id__repeat_post.return_value = ( sample_test_run_execution ) - with patch("th_cli.commands.test_run_execution.SyncApis", return_value=mock_sync_apis): + with ( + patch("th_cli.commands.test_run_execution.get_client", return_value=mock_api_client), + patch("th_cli.commands.test_run_execution.AsyncApis", return_value=mock_async_apis), + ): result = cli_runner.invoke(test_run_execution, ["repeat", "--id", "1", "--title", "Custom Title"]) assert result.exit_code == 0 @@ -93,11 +103,13 @@ def test_repeat_with_custom_title( def test_repeat_with_start( self, cli_runner: CliRunner, - mock_sync_apis: Mock, + mock_async_apis: Mock, + mock_api_client: Mock, sample_test_run_execution: api_models.TestRunExecutionWithChildren, ) -> None: - """--start also calls the start endpoint for the newly created execution.""" - api = mock_sync_apis.test_run_executions_api + """--start also starts the repeated execution and attaches to it the same way + `run-tests` does, streaming live progress instead of just reporting a static message.""" + api = mock_async_apis.test_run_executions_api api.repeat_test_run_execution_api_v1_test_run_executions__id__repeat_post.return_value = ( sample_test_run_execution ) @@ -105,28 +117,44 @@ def test_repeat_with_start( sample_test_run_execution ) - with patch("th_cli.commands.test_run_execution.SyncApis", return_value=mock_sync_apis): + with ( + patch("th_cli.commands.test_run_execution.get_client", return_value=mock_api_client), + patch("th_cli.commands.test_run_execution.AsyncApis", return_value=mock_async_apis), + patch("th_cli.commands.test_run_execution.TestRunSocket") as mock_socket_class, + ): + mock_socket = Mock() + mock_socket.connect_websocket = AsyncMock() + mock_socket_class.return_value = mock_socket + result = cli_runner.invoke(test_run_execution, ["repeat", "--id", "1", "--start"]) assert result.exit_code == 0 - assert f"Test run execution {sample_test_run_execution.id} started." in result.output + assert "Starting Test run" in result.output + assert str(sample_test_run_execution.id) in result.output api.start_test_run_execution_api_v1_test_run_executions__id__start_post.assert_called_once_with( id=sample_test_run_execution.id ) + mock_socket_class.assert_called_once_with(sample_test_run_execution) + mock_socket.connect_websocket.assert_called_once() + assert mock_socket.run == sample_test_run_execution def test_repeat_without_start_does_not_start( self, cli_runner: CliRunner, - mock_sync_apis: Mock, + mock_async_apis: Mock, + mock_api_client: Mock, sample_test_run_execution: api_models.TestRunExecutionWithChildren, ) -> None: """Without --start, the repeated execution is created but not started.""" - api = mock_sync_apis.test_run_executions_api + api = mock_async_apis.test_run_executions_api api.repeat_test_run_execution_api_v1_test_run_executions__id__repeat_post.return_value = ( sample_test_run_execution ) - with patch("th_cli.commands.test_run_execution.SyncApis", return_value=mock_sync_apis): + with ( + patch("th_cli.commands.test_run_execution.get_client", return_value=mock_api_client), + patch("th_cli.commands.test_run_execution.AsyncApis", return_value=mock_async_apis), + ): result = cli_runner.invoke(test_run_execution, ["repeat", "--id", "1"]) assert result.exit_code == 0 @@ -135,11 +163,12 @@ def test_repeat_without_start_does_not_start( def test_repeat_with_start_api_error( self, cli_runner: CliRunner, - mock_sync_apis: Mock, + mock_async_apis: Mock, + mock_api_client: Mock, sample_test_run_execution: api_models.TestRunExecutionWithChildren, ) -> None: """A failure to start the repeated execution is surfaced via the standard error-handling path.""" - api = mock_sync_apis.test_run_executions_api + api = mock_async_apis.test_run_executions_api api.repeat_test_run_execution_api_v1_test_run_executions__id__repeat_post.return_value = ( sample_test_run_execution ) @@ -147,7 +176,15 @@ def test_repeat_with_start_api_error( status_code=500, content=b"Internal Server Error" ) - with patch("th_cli.commands.test_run_execution.SyncApis", return_value=mock_sync_apis): + with ( + patch("th_cli.commands.test_run_execution.get_client", return_value=mock_api_client), + patch("th_cli.commands.test_run_execution.AsyncApis", return_value=mock_async_apis), + patch("th_cli.commands.test_run_execution.TestRunSocket") as mock_socket_class, + ): + mock_socket = Mock() + mock_socket.connect_websocket = AsyncMock() + mock_socket_class.return_value = mock_socket + result = cli_runner.invoke(test_run_execution, ["repeat", "--id", "1", "--start"]) assert result.exit_code == 1 @@ -159,11 +196,12 @@ def test_repeat_with_start_api_error( def test_repeat_with_start_conflict( self, cli_runner: CliRunner, - mock_sync_apis: Mock, + mock_async_apis: Mock, + mock_api_client: Mock, sample_test_run_execution: api_models.TestRunExecutionWithChildren, ) -> None: """A 409 (e.g. test engine busy) makes clear the execution was still created.""" - api = mock_sync_apis.test_run_executions_api + api = mock_async_apis.test_run_executions_api api.repeat_test_run_execution_api_v1_test_run_executions__id__repeat_post.return_value = ( sample_test_run_execution ) @@ -171,65 +209,141 @@ def test_repeat_with_start_conflict( status_code=409, content={"detail": "Test Engine is busy."} ) - with patch("th_cli.commands.test_run_execution.SyncApis", return_value=mock_sync_apis): + with ( + patch("th_cli.commands.test_run_execution.get_client", return_value=mock_api_client), + patch("th_cli.commands.test_run_execution.AsyncApis", return_value=mock_async_apis), + patch("th_cli.commands.test_run_execution.TestRunSocket") as mock_socket_class, + ): + mock_socket = Mock() + mock_socket.connect_websocket = AsyncMock() + mock_socket_class.return_value = mock_socket + result = cli_runner.invoke(test_run_execution, ["repeat", "--id", "1", "--start"]) assert result.exit_code == 1 assert f"Execution {sample_test_run_execution.id} was created but could not be started" in result.output assert "Test Engine is busy." in result.output - def test_repeat_not_found(self, cli_runner: CliRunner, mock_sync_apis: Mock) -> None: + def test_repeat_with_start_timeout( + self, + cli_runner: CliRunner, + mock_async_apis: Mock, + mock_api_client: Mock, + sample_test_run_execution: api_models.TestRunExecutionWithChildren, + ) -> None: + """A timeout while starting the repeated execution is surfaced as a clean, readable + error instead of a raw traceback.""" + api = mock_async_apis.test_run_executions_api + api.repeat_test_run_execution_api_v1_test_run_executions__id__repeat_post.return_value = ( + sample_test_run_execution + ) + api.start_test_run_execution_api_v1_test_run_executions__id__start_post.side_effect = ( + ResponseHandlingException(ReadTimeout("timed out")) + ) + + with ( + patch("th_cli.commands.test_run_execution.get_client", return_value=mock_api_client), + patch("th_cli.commands.test_run_execution.AsyncApis", return_value=mock_async_apis), + patch("th_cli.commands.test_run_execution.TestRunSocket") as mock_socket_class, + ): + mock_socket = Mock() + mock_socket.connect_websocket = AsyncMock() + mock_socket_class.return_value = mock_socket + + result = cli_runner.invoke(test_run_execution, ["repeat", "--id", "1", "--start"]) + + assert result.exit_code == 1 + assert "Timed out waiting for the server" in result.output + + def test_repeat_not_found(self, cli_runner: CliRunner, mock_async_apis: Mock, mock_api_client: Mock) -> None: """A 404 from the API is surfaced as a clear 'not found' error.""" - api = mock_sync_apis.test_run_executions_api + api = mock_async_apis.test_run_executions_api api.repeat_test_run_execution_api_v1_test_run_executions__id__repeat_post.side_effect = UnexpectedResponse( status_code=404, content={"detail": "TestRunExecution not found"} ) - with patch("th_cli.commands.test_run_execution.SyncApis", return_value=mock_sync_apis): + with ( + patch("th_cli.commands.test_run_execution.get_client", return_value=mock_api_client), + patch("th_cli.commands.test_run_execution.AsyncApis", return_value=mock_async_apis), + ): result = cli_runner.invoke(test_run_execution, ["repeat", "--id", "999"]) assert result.exit_code == 1 assert "Test run execution with ID '999' not found." in result.output - def test_repeat_not_found_does_not_start(self, cli_runner: CliRunner, mock_sync_apis: Mock) -> None: + def test_repeat_not_found_does_not_start( + self, cli_runner: CliRunner, mock_async_apis: Mock, mock_api_client: Mock + ) -> None: """A failed repeat (404) must not attempt to start anything, even with --start.""" - api = mock_sync_apis.test_run_executions_api + api = mock_async_apis.test_run_executions_api api.repeat_test_run_execution_api_v1_test_run_executions__id__repeat_post.side_effect = UnexpectedResponse( status_code=404, content={"detail": "TestRunExecution not found"} ) - with patch("th_cli.commands.test_run_execution.SyncApis", return_value=mock_sync_apis): + with ( + patch("th_cli.commands.test_run_execution.get_client", return_value=mock_api_client), + patch("th_cli.commands.test_run_execution.AsyncApis", return_value=mock_async_apis), + ): result = cli_runner.invoke(test_run_execution, ["repeat", "--id", "999", "--start"]) assert result.exit_code == 1 api.start_test_run_execution_api_v1_test_run_executions__id__start_post.assert_not_called() - def test_repeat_other_api_error(self, cli_runner: CliRunner, mock_sync_apis: Mock) -> None: + def test_repeat_other_api_error( + self, cli_runner: CliRunner, mock_async_apis: Mock, mock_api_client: Mock + ) -> None: """A non-404 API error is surfaced via the standard error-handling path.""" - api = mock_sync_apis.test_run_executions_api + api = mock_async_apis.test_run_executions_api api.repeat_test_run_execution_api_v1_test_run_executions__id__repeat_post.side_effect = UnexpectedResponse( status_code=500, content=b"Internal Server Error" ) - with patch("th_cli.commands.test_run_execution.SyncApis", return_value=mock_sync_apis): + with ( + patch("th_cli.commands.test_run_execution.get_client", return_value=mock_api_client), + patch("th_cli.commands.test_run_execution.AsyncApis", return_value=mock_async_apis), + ): result = cli_runner.invoke(test_run_execution, ["repeat", "--id", "1"]) assert result.exit_code == 1 assert "Failed to repeat test run execution '1' (Status: 500) - Internal Server Error" in result.output - def test_repeat_other_api_error_does_not_start(self, cli_runner: CliRunner, mock_sync_apis: Mock) -> None: + def test_repeat_other_api_error_does_not_start( + self, cli_runner: CliRunner, mock_async_apis: Mock, mock_api_client: Mock + ) -> None: """A failed repeat (non-404) must not attempt to start anything, even with --start.""" - api = mock_sync_apis.test_run_executions_api + api = mock_async_apis.test_run_executions_api api.repeat_test_run_execution_api_v1_test_run_executions__id__repeat_post.side_effect = UnexpectedResponse( status_code=500, content=b"Internal Server Error" ) - with patch("th_cli.commands.test_run_execution.SyncApis", return_value=mock_sync_apis): + with ( + patch("th_cli.commands.test_run_execution.get_client", return_value=mock_api_client), + patch("th_cli.commands.test_run_execution.AsyncApis", return_value=mock_async_apis), + ): result = cli_runner.invoke(test_run_execution, ["repeat", "--id", "1", "--start"]) assert result.exit_code == 1 api.start_test_run_execution_api_v1_test_run_executions__id__start_post.assert_not_called() + def test_repeat_timeout_error( + self, cli_runner: CliRunner, mock_async_apis: Mock, mock_api_client: Mock + ) -> None: + """A timeout while repeating the execution is surfaced as a clean, readable error + instead of a raw traceback (the original bug report for this command).""" + api = mock_async_apis.test_run_executions_api + api.repeat_test_run_execution_api_v1_test_run_executions__id__repeat_post.side_effect = ( + ResponseHandlingException(ReadTimeout("timed out")) + ) + + with ( + patch("th_cli.commands.test_run_execution.get_client", return_value=mock_api_client), + patch("th_cli.commands.test_run_execution.AsyncApis", return_value=mock_async_apis), + ): + result = cli_runner.invoke(test_run_execution, ["repeat", "--id", "1"]) + + assert result.exit_code == 1 + assert "Timed out waiting for the server" in result.output + def test_repeat_configuration_error(self, cli_runner: CliRunner) -> None: """A ConfigurationError from get_client is surfaced to the user.""" with patch( @@ -323,6 +437,20 @@ def test_export_write_failure_raises_cli_error(self, cli_runner: CliRunner, mock assert "Failed to write export file '/no/such/dir/out.json'" in result.output assert "Permission denied" in result.output + def test_export_timeout_error(self, cli_runner: CliRunner, mock_sync_apis: Mock) -> None: + """A timeout while exporting is surfaced as a clean, readable error instead of a raw + traceback (the original bug report for this command).""" + api = mock_sync_apis.test_run_executions_api + api.export_test_run_execution_api_v1_test_run_executions__id__export_get.side_effect = ( + ResponseHandlingException(ReadTimeout("timed out")) + ) + + with patch("th_cli.commands.test_run_execution.SyncApis", return_value=mock_sync_apis): + result = cli_runner.invoke(test_run_execution, ["export", "--id", "1"]) + + assert result.exit_code == 1 + assert "Timed out waiting for the server" in result.output + def test_export_help_message(self, cli_runner: CliRunner) -> None: """Test the help message for the export command.""" result = cli_runner.invoke(test_run_execution, ["export", "--help"]) @@ -400,6 +528,23 @@ def test_import_requires_project_id(self, cli_runner: CliRunner, temp_dir: Path) assert result.exit_code != 0 assert "Missing option" in result.output or "--project-id" in result.output + def test_import_timeout_error(self, cli_runner: CliRunner, mock_sync_apis: Mock, temp_dir: Path) -> None: + """A timeout while importing is surfaced as a clean, readable error instead of a raw + traceback (the original bug report for this command).""" + import_file = temp_dir / "run.json" + import_file.write_text(_make_exported_execution().model_dump_json()) + + api = mock_sync_apis.test_run_executions_api + api.import_test_run_execution_api_v1_test_run_executions_import_post.side_effect = ( + ResponseHandlingException(ReadTimeout("timed out")) + ) + + with patch("th_cli.commands.test_run_execution.SyncApis", return_value=mock_sync_apis): + result = cli_runner.invoke(test_run_execution, ["import", "--file", str(import_file), "--project-id", "1"]) + + assert result.exit_code == 1 + assert "Timed out waiting for the server" in result.output + def test_import_help_message(self, cli_runner: CliRunner) -> None: """Test the help message for the import command.""" result = cli_runner.invoke(test_run_execution, ["import", "--help"]) diff --git a/th_cli/commands/test_run_execution.py b/th_cli/commands/test_run_execution.py index 10d68e6..2fbfc1f 100644 --- a/th_cli/commands/test_run_execution.py +++ b/th_cli/commands/test_run_execution.py @@ -13,29 +13,42 @@ # See the License for the specific language governing permissions and # limitations under the License. # +import asyncio from contextlib import closing from pathlib import Path import click +from httpx import Timeout, TimeoutException -from th_cli.api_lib_autogen.api_client import SyncApis -from th_cli.api_lib_autogen.exceptions import UnexpectedResponse -from th_cli.api_lib_autogen.models import BodyImportTestRunExecutionApiV1TestRunExecutionsImportPost +from th_cli.api_lib_autogen.api_client import AsyncApis, SyncApis +from th_cli.api_lib_autogen.exceptions import ResponseHandlingException, UnexpectedResponse +from th_cli.api_lib_autogen.models import ( + BodyImportTestRunExecutionApiV1TestRunExecutionsImportPost, + TestRunExecutionWithChildren, +) +from th_cli.async_cmd import async_cmd from th_cli.client import get_client from th_cli.colorize import ( colorize_cmd_help, colorize_header, colorize_help, + colorize_key_value, colorize_state, colorize_success, italic, ) from th_cli.exceptions import CLIError, handle_api_error, handle_file_error +from th_cli.test_run.websocket import TestRunSocket from th_cli.utils import __print_json table_format_header = "{:<6} {:<55} {}" table_format = "{:<6} {} {}" +# Repeating/exporting/importing a test run execution transfers its full config +# and logs, which can take significantly longer than httpx's 5s default read +# timeout for executions with a lot of log data. +TEST_RUN_EXECUTION_IO_TIMEOUT = Timeout(120.0, connect=10.0) # 120s total, 10s connect + _list_options = [ click.option( "--id", @@ -283,16 +296,27 @@ def pics_export(id: int, output_file: str) -> None: "--start", is_flag=True, default=False, - help=colorize_help("Start the repeated execution right away (does not wait for completion or stream progress)"), + help=colorize_help( + "Start the repeated execution right away, attaching to it the same way 'run-tests' " + "does: streaming live progress and forwarding any user prompts to this terminal" + ), ) -def repeat(id: int, title: str | None, start: bool) -> None: +@async_cmd +async def repeat(id: int, title: str | None, start: bool) -> None: + client = None try: - with closing(get_client()) as client: - sync_apis = SyncApis(client) - __repeat_test_run_execution(sync_apis, id, title, start) + client = get_client() + client._async_client.timeout = TEST_RUN_EXECUTION_IO_TIMEOUT + async_apis = AsyncApis(client) + new_execution = await __repeat_test_run_execution(async_apis, id, title) + if start: + await __start_and_stream_repeated_execution(async_apis, new_execution) except CLIError: raise # Re-raise CLI Errors as-is + finally: + if client: + await client.aclose() @test_run_execution.command( @@ -317,6 +341,7 @@ def repeat(id: int, title: str | None, start: bool) -> None: def export(id: int, output_file: str | None) -> None: try: with closing(get_client()) as client: + client._async_client.timeout = TEST_RUN_EXECUTION_IO_TIMEOUT sync_apis = SyncApis(client) __export_test_run_execution(sync_apis, id, output_file) @@ -346,6 +371,7 @@ def export(id: int, output_file: str | None) -> None: def import_execution(file: str, project_id: int) -> None: try: with closing(get_client()) as client: + client._async_client.timeout = TEST_RUN_EXECUTION_IO_TIMEOUT sync_apis = SyncApis(client) __import_test_run_execution(sync_apis, file, project_id) @@ -564,34 +590,75 @@ def _extract_error_detail(e: UnexpectedResponse) -> str: return str(content) -def __repeat_test_run_execution(sync_apis: SyncApis, id: int, title: str | None, start: bool) -> None: - try: - test_run_execution_api = sync_apis.test_run_executions_api - new_execution = test_run_execution_api.repeat_test_run_execution_api_v1_test_run_executions__id__repeat_post( - id=id, title=title +def _timeout_or_connection_error(e: ResponseHandlingException, operation: str) -> str: + """Turn a low-level ResponseHandlingException into a readable CLI error message.""" + if isinstance(e.error, TimeoutException): + return ( + f"Timed out waiting for the server while trying to {operation} " + f"(waited {int(TEST_RUN_EXECUTION_IO_TIMEOUT.read)}s). Executions with a lot of " + "log data can take longer than that to transfer; please try again." ) + return f"Could not {operation}: {e}. Please check if the API server is running and accessible." + + +async def __repeat_test_run_execution( + async_apis: AsyncApis, id: int, title: str | None +) -> TestRunExecutionWithChildren: + try: + test_run_execution_api = async_apis.test_run_executions_api + repeat_call = test_run_execution_api.repeat_test_run_execution_api_v1_test_run_executions__id__repeat_post + new_execution = await repeat_call(id=id, title=title) click.echo( colorize_success(f"Test run execution {id} repeated as new execution {new_execution.id}") + f" ('{new_execution.title}')" ) + return new_execution except UnexpectedResponse as e: if e.status_code == 404: raise CLIError(f"Test run execution with ID '{id}' not found.") handle_api_error(e, f"repeat test run execution '{id}'") + except ResponseHandlingException as e: + raise CLIError(_timeout_or_connection_error(e, f"repeat test run execution '{id}'")) + - if start: - try: - test_run_execution_api.start_test_run_execution_api_v1_test_run_executions__id__start_post( - id=new_execution.id +async def __start_and_stream_repeated_execution( + async_apis: AsyncApis, new_execution: TestRunExecutionWithChildren +) -> None: + """Start a repeated execution and attach to it the same way 'run-tests' does: + streaming live test progress and forwarding any user prompts to this terminal.""" + test_run_execution_api = async_apis.test_run_executions_api + + header = colorize_header("Starting Test run") + title = colorize_key_value("Title", new_execution.title) + test_run_id = colorize_key_value("ID", str(new_execution.id)) + click.echo("") + click.echo(f"{header}:\n- {title}\n- {test_run_id}\n") + + socket = TestRunSocket(new_execution) + socket_task = asyncio.create_task(socket.connect_websocket()) + try: + start_call = test_run_execution_api.start_test_run_execution_api_v1_test_run_executions__id__start_post + started_execution = await start_call(id=new_execution.id) + except UnexpectedResponse as e: + await _cancel_socket_task(socket_task) + if e.status_code == 409: + raise CLIError( + f"Execution {new_execution.id} was created but could not be started: " + f"{_extract_error_detail(e)}" ) - click.echo(colorize_success(f"Test run execution {new_execution.id} started.")) - except UnexpectedResponse as e: - if e.status_code == 409: - raise CLIError( - f"Execution {new_execution.id} was created but could not be started: " - f"{_extract_error_detail(e)}" - ) - handle_api_error(e, f"start repeated test run execution '{new_execution.id}'") + handle_api_error(e, f"start repeated test run execution '{new_execution.id}'") + except ResponseHandlingException as e: + await _cancel_socket_task(socket_task) + raise CLIError(_timeout_or_connection_error(e, f"start repeated test run execution '{new_execution.id}'")) + + socket.run = started_execution + await socket_task + + +async def _cancel_socket_task(socket_task: "asyncio.Task[None]") -> None: + """Cancel a pending websocket task and wait for it to finish unwinding.""" + socket_task.cancel() + await asyncio.gather(socket_task, return_exceptions=True) def __export_test_run_execution(sync_apis: SyncApis, id: int, output_file: str | None) -> None: @@ -602,6 +669,8 @@ def __export_test_run_execution(sync_apis: SyncApis, id: int, output_file: str | if e.status_code == 404: raise CLIError(f"Test run execution with ID '{id}' not found.") handle_api_error(e, f"export test run execution '{id}'") + except ResponseHandlingException as e: + raise CLIError(_timeout_or_connection_error(e, f"export test run execution '{id}'")) if not output_file: if exported.test_run_execution.title: @@ -638,6 +707,8 @@ def __import_test_run_execution(sync_apis: SyncApis, file: str, project_id: int) ) except UnexpectedResponse as e: handle_api_error(e, f"import test run execution from '{file}'") + except ResponseHandlingException as e: + raise CLIError(_timeout_or_connection_error(e, f"import test run execution from '{file}'")) def __print_table_test_executions(test_execution: list) -> None: From e894bba9152957a9478d318d78f8761e25dc359e Mon Sep 17 00:00:00 2001 From: Romulo Quidute Filho <rquidute@apple.com> Date: Thu, 10 Sep 2026 15:36:36 -0300 Subject: [PATCH 04/10] Make repeat start and stream by default, matching the frontend's Repeat action Previously 'repeat' required an opt-in --start flag to attach to the new execution; by default it only created it, which didn't match how the frontend's "Repeat" button always starts the execution immediately. Flip the default: 'repeat' now always starts the repeated execution and streams live progress the same way 'run-tests' does, unless --no-start is passed to only create it without starting. --- README.md | 9 +- ...test_run_execution_repeat_export_import.py | 114 ++++++------------ th_cli/commands/test_run_execution.py | 12 +- 3 files changed, 46 insertions(+), 89 deletions(-) diff --git a/README.md b/README.md index 129a3e0..7fe4bd5 100644 --- a/README.md +++ b/README.md @@ -148,11 +148,12 @@ For JSON respond, add `--json` to the command. ### Test Run Execution Repeat Run `th-cli test-run-execution repeat --id {id}` to create a new test run execution with -the same selected tests and config as an existing one. Use `--title` to override the +the same selected tests and config as an existing one, then start it and attach to it the +same way `run-tests` does (and the frontend's "Repeat" action does): streaming live test +progress and forwarding any user prompts to this terminal. Use `--title` to override the generated title (defaults to the original title with an updated timestamp; the backend -always appends a timestamp regardless). Add `--start` to start the repeated execution -and attach to it the same way `run-tests` does: streaming live test progress and -forwarding any user prompts to this terminal. +always appends a timestamp regardless). Add `--no-start` to only create the repeated +execution without starting it. ### Test Run Execution Export diff --git a/tests/test_test_run_execution_repeat_export_import.py b/tests/test_test_run_execution_repeat_export_import.py index e855246..ff8c7ed 100644 --- a/tests/test_test_run_execution_repeat_export_import.py +++ b/tests/test_test_run_execution_repeat_export_import.py @@ -56,24 +56,41 @@ def test_repeat_success( mock_api_client: Mock, sample_test_run_execution: api_models.TestRunExecutionWithChildren, ) -> None: - """A successful repeat reports the new execution's ID and title.""" + """By default, repeat creates the new execution, starts it, and attaches to it the + same way `run-tests` does (and the frontend's 'Repeat' action does): streaming live + progress instead of just reporting a static message.""" api = mock_async_apis.test_run_executions_api api.repeat_test_run_execution_api_v1_test_run_executions__id__repeat_post.return_value = ( sample_test_run_execution ) + api.start_test_run_execution_api_v1_test_run_executions__id__start_post.return_value = ( + sample_test_run_execution + ) with ( patch("th_cli.commands.test_run_execution.get_client", return_value=mock_api_client), patch("th_cli.commands.test_run_execution.AsyncApis", return_value=mock_async_apis), + patch("th_cli.commands.test_run_execution.TestRunSocket") as mock_socket_class, ): + mock_socket = Mock() + mock_socket.connect_websocket = AsyncMock() + mock_socket_class.return_value = mock_socket + result = cli_runner.invoke(test_run_execution, ["repeat", "--id", "1"]) assert result.exit_code == 0 assert f"repeated as new execution {sample_test_run_execution.id}" in result.output assert sample_test_run_execution.title in result.output + assert "Starting Test run" in result.output api.repeat_test_run_execution_api_v1_test_run_executions__id__repeat_post.assert_called_once_with( id=1, title=None ) + api.start_test_run_execution_api_v1_test_run_executions__id__start_post.assert_called_once_with( + id=sample_test_run_execution.id + ) + mock_socket_class.assert_called_once_with(sample_test_run_execution) + mock_socket.connect_websocket.assert_called_once() + assert mock_socket.run == sample_test_run_execution mock_api_client.aclose.assert_called_once() def test_repeat_with_custom_title( @@ -88,31 +105,6 @@ def test_repeat_with_custom_title( api.repeat_test_run_execution_api_v1_test_run_executions__id__repeat_post.return_value = ( sample_test_run_execution ) - - with ( - patch("th_cli.commands.test_run_execution.get_client", return_value=mock_api_client), - patch("th_cli.commands.test_run_execution.AsyncApis", return_value=mock_async_apis), - ): - result = cli_runner.invoke(test_run_execution, ["repeat", "--id", "1", "--title", "Custom Title"]) - - assert result.exit_code == 0 - api.repeat_test_run_execution_api_v1_test_run_executions__id__repeat_post.assert_called_once_with( - id=1, title="Custom Title" - ) - - def test_repeat_with_start( - self, - cli_runner: CliRunner, - mock_async_apis: Mock, - mock_api_client: Mock, - sample_test_run_execution: api_models.TestRunExecutionWithChildren, - ) -> None: - """--start also starts the repeated execution and attaches to it the same way - `run-tests` does, streaming live progress instead of just reporting a static message.""" - api = mock_async_apis.test_run_executions_api - api.repeat_test_run_execution_api_v1_test_run_executions__id__repeat_post.return_value = ( - sample_test_run_execution - ) api.start_test_run_execution_api_v1_test_run_executions__id__start_post.return_value = ( sample_test_run_execution ) @@ -126,26 +118,21 @@ def test_repeat_with_start( mock_socket.connect_websocket = AsyncMock() mock_socket_class.return_value = mock_socket - result = cli_runner.invoke(test_run_execution, ["repeat", "--id", "1", "--start"]) + result = cli_runner.invoke(test_run_execution, ["repeat", "--id", "1", "--title", "Custom Title"]) assert result.exit_code == 0 - assert "Starting Test run" in result.output - assert str(sample_test_run_execution.id) in result.output - api.start_test_run_execution_api_v1_test_run_executions__id__start_post.assert_called_once_with( - id=sample_test_run_execution.id + api.repeat_test_run_execution_api_v1_test_run_executions__id__repeat_post.assert_called_once_with( + id=1, title="Custom Title" ) - mock_socket_class.assert_called_once_with(sample_test_run_execution) - mock_socket.connect_websocket.assert_called_once() - assert mock_socket.run == sample_test_run_execution - def test_repeat_without_start_does_not_start( + def test_repeat_no_start( self, cli_runner: CliRunner, mock_async_apis: Mock, mock_api_client: Mock, sample_test_run_execution: api_models.TestRunExecutionWithChildren, ) -> None: - """Without --start, the repeated execution is created but not started.""" + """--no-start only creates the repeated execution, without starting or attaching to it.""" api = mock_async_apis.test_run_executions_api api.repeat_test_run_execution_api_v1_test_run_executions__id__repeat_post.return_value = ( sample_test_run_execution @@ -155,12 +142,13 @@ def test_repeat_without_start_does_not_start( patch("th_cli.commands.test_run_execution.get_client", return_value=mock_api_client), patch("th_cli.commands.test_run_execution.AsyncApis", return_value=mock_async_apis), ): - result = cli_runner.invoke(test_run_execution, ["repeat", "--id", "1"]) + result = cli_runner.invoke(test_run_execution, ["repeat", "--id", "1", "--no-start"]) assert result.exit_code == 0 + assert "Starting Test run" not in result.output api.start_test_run_execution_api_v1_test_run_executions__id__start_post.assert_not_called() - def test_repeat_with_start_api_error( + def test_repeat_start_api_error( self, cli_runner: CliRunner, mock_async_apis: Mock, @@ -185,7 +173,7 @@ def test_repeat_with_start_api_error( mock_socket.connect_websocket = AsyncMock() mock_socket_class.return_value = mock_socket - result = cli_runner.invoke(test_run_execution, ["repeat", "--id", "1", "--start"]) + result = cli_runner.invoke(test_run_execution, ["repeat", "--id", "1"]) assert result.exit_code == 1 assert ( @@ -193,7 +181,7 @@ def test_repeat_with_start_api_error( "(Status: 500) - Internal Server Error" in result.output ) - def test_repeat_with_start_conflict( + def test_repeat_start_conflict( self, cli_runner: CliRunner, mock_async_apis: Mock, @@ -218,13 +206,13 @@ def test_repeat_with_start_conflict( mock_socket.connect_websocket = AsyncMock() mock_socket_class.return_value = mock_socket - result = cli_runner.invoke(test_run_execution, ["repeat", "--id", "1", "--start"]) + result = cli_runner.invoke(test_run_execution, ["repeat", "--id", "1"]) assert result.exit_code == 1 assert f"Execution {sample_test_run_execution.id} was created but could not be started" in result.output assert "Test Engine is busy." in result.output - def test_repeat_with_start_timeout( + def test_repeat_start_timeout( self, cli_runner: CliRunner, mock_async_apis: Mock, @@ -250,13 +238,13 @@ def test_repeat_with_start_timeout( mock_socket.connect_websocket = AsyncMock() mock_socket_class.return_value = mock_socket - result = cli_runner.invoke(test_run_execution, ["repeat", "--id", "1", "--start"]) + result = cli_runner.invoke(test_run_execution, ["repeat", "--id", "1"]) assert result.exit_code == 1 assert "Timed out waiting for the server" in result.output def test_repeat_not_found(self, cli_runner: CliRunner, mock_async_apis: Mock, mock_api_client: Mock) -> None: - """A 404 from the API is surfaced as a clear 'not found' error.""" + """A 404 from the API is surfaced as a clear 'not found' error, and nothing is started.""" api = mock_async_apis.test_run_executions_api api.repeat_test_run_execution_api_v1_test_run_executions__id__repeat_post.side_effect = UnexpectedResponse( status_code=404, content={"detail": "TestRunExecution not found"} @@ -270,29 +258,12 @@ def test_repeat_not_found(self, cli_runner: CliRunner, mock_async_apis: Mock, mo assert result.exit_code == 1 assert "Test run execution with ID '999' not found." in result.output - - def test_repeat_not_found_does_not_start( - self, cli_runner: CliRunner, mock_async_apis: Mock, mock_api_client: Mock - ) -> None: - """A failed repeat (404) must not attempt to start anything, even with --start.""" - api = mock_async_apis.test_run_executions_api - api.repeat_test_run_execution_api_v1_test_run_executions__id__repeat_post.side_effect = UnexpectedResponse( - status_code=404, content={"detail": "TestRunExecution not found"} - ) - - with ( - patch("th_cli.commands.test_run_execution.get_client", return_value=mock_api_client), - patch("th_cli.commands.test_run_execution.AsyncApis", return_value=mock_async_apis), - ): - result = cli_runner.invoke(test_run_execution, ["repeat", "--id", "999", "--start"]) - - assert result.exit_code == 1 api.start_test_run_execution_api_v1_test_run_executions__id__start_post.assert_not_called() def test_repeat_other_api_error( self, cli_runner: CliRunner, mock_async_apis: Mock, mock_api_client: Mock ) -> None: - """A non-404 API error is surfaced via the standard error-handling path.""" + """A non-404 API error is surfaced via the standard error-handling path, and nothing is started.""" api = mock_async_apis.test_run_executions_api api.repeat_test_run_execution_api_v1_test_run_executions__id__repeat_post.side_effect = UnexpectedResponse( status_code=500, content=b"Internal Server Error" @@ -306,23 +277,6 @@ def test_repeat_other_api_error( assert result.exit_code == 1 assert "Failed to repeat test run execution '1' (Status: 500) - Internal Server Error" in result.output - - def test_repeat_other_api_error_does_not_start( - self, cli_runner: CliRunner, mock_async_apis: Mock, mock_api_client: Mock - ) -> None: - """A failed repeat (non-404) must not attempt to start anything, even with --start.""" - api = mock_async_apis.test_run_executions_api - api.repeat_test_run_execution_api_v1_test_run_executions__id__repeat_post.side_effect = UnexpectedResponse( - status_code=500, content=b"Internal Server Error" - ) - - with ( - patch("th_cli.commands.test_run_execution.get_client", return_value=mock_api_client), - patch("th_cli.commands.test_run_execution.AsyncApis", return_value=mock_async_apis), - ): - result = cli_runner.invoke(test_run_execution, ["repeat", "--id", "1", "--start"]) - - assert result.exit_code == 1 api.start_test_run_execution_api_v1_test_run_executions__id__start_post.assert_not_called() def test_repeat_timeout_error( @@ -369,7 +323,7 @@ def test_repeat_help_message(self, cli_runner: CliRunner) -> None: assert result.exit_code == 0 assert "--id" in result.output assert "--title" in result.output - assert "--start" in result.output + assert "--no-start" in result.output @pytest.mark.unit diff --git a/th_cli/commands/test_run_execution.py b/th_cli/commands/test_run_execution.py index 2fbfc1f..3d93970 100644 --- a/th_cli/commands/test_run_execution.py +++ b/th_cli/commands/test_run_execution.py @@ -293,16 +293,18 @@ def pics_export(id: int, output_file: str) -> None: ), ) @click.option( - "--start", + "--no-start", is_flag=True, default=False, help=colorize_help( - "Start the repeated execution right away, attaching to it the same way 'run-tests' " - "does: streaming live progress and forwarding any user prompts to this terminal" + "Only create the repeated execution without starting it. By default, 'repeat' starts " + "the new execution right away and attaches to it the same way 'run-tests' does (and " + "the frontend's 'Repeat' action does): streaming live progress and forwarding any " + "user prompts to this terminal" ), ) @async_cmd -async def repeat(id: int, title: str | None, start: bool) -> None: +async def repeat(id: int, title: str | None, no_start: bool) -> None: client = None try: client = get_client() @@ -310,7 +312,7 @@ async def repeat(id: int, title: str | None, start: bool) -> None: async_apis = AsyncApis(client) new_execution = await __repeat_test_run_execution(async_apis, id, title) - if start: + if not no_start: await __start_and_stream_repeated_execution(async_apis, new_execution) except CLIError: raise # Re-raise CLI Errors as-is From f24a64048548f321e5a81eff9ae34e96ef3fb5c5 Mon Sep 17 00:00:00 2001 From: Romulo Quidute Filho <rquidute@apple.com> Date: Thu, 10 Sep 2026 20:28:26 -0300 Subject: [PATCH 05/10] Fix repeat command printing raw log records instead of tree output 'test-run-execution repeat' never called configure_logger_for_run() before opening the websocket, so loguru's default stderr sink stayed active and every incoming TestLogRecord was printed raw to the terminal, interleaved with the tree output and prompts. Mirror run-tests: configure the logger (and optional log streaming) before connecting the socket, show the same log-viewer banner and final log path, and add --no-color/--no-streaming flags for parity. --- th_cli/commands/test_run_execution.py | 48 ++++++++++++++++++++++++--- 1 file changed, 43 insertions(+), 5 deletions(-) diff --git a/th_cli/commands/test_run_execution.py b/th_cli/commands/test_run_execution.py index 3d93970..3b6a9e4 100644 --- a/th_cli/commands/test_run_execution.py +++ b/th_cli/commands/test_run_execution.py @@ -36,8 +36,10 @@ colorize_state, colorize_success, italic, + set_colors_enabled, ) from th_cli.exceptions import CLIError, handle_api_error, handle_file_error +from th_cli.test_run import logging as test_logging from th_cli.test_run.websocket import TestRunSocket from th_cli.utils import __print_json @@ -303,8 +305,21 @@ def pics_export(id: int, output_file: str) -> None: "user prompts to this terminal" ), ) +@click.option( + "--no-color", + is_flag=True, + help=colorize_help("Disable colored output for test execution status."), +) +@click.option( + "--no-streaming", + is_flag=True, + help=colorize_help("Disable real-time log streaming via web browser (enabled by default)."), +) @async_cmd -async def repeat(id: int, title: str | None, no_start: bool) -> None: +async def repeat(id: int, title: str | None, no_start: bool, no_color: bool, no_streaming: bool) -> None: + if no_color: + set_colors_enabled(False) + client = None try: client = get_client() @@ -313,7 +328,7 @@ async def repeat(id: int, title: str | None, no_start: bool) -> None: new_execution = await __repeat_test_run_execution(async_apis, id, title) if not no_start: - await __start_and_stream_repeated_execution(async_apis, new_execution) + await __start_and_stream_repeated_execution(async_apis, new_execution, enable_streaming=not no_streaming) except CLIError: raise # Re-raise CLI Errors as-is finally: @@ -624,18 +639,37 @@ async def __repeat_test_run_execution( async def __start_and_stream_repeated_execution( - async_apis: AsyncApis, new_execution: TestRunExecutionWithChildren + async_apis: AsyncApis, new_execution: TestRunExecutionWithChildren, enable_streaming: bool = True ) -> None: """Start a repeated execution and attach to it the same way 'run-tests' does: streaming live test progress and forwarding any user prompts to this terminal.""" test_run_execution_api = async_apis.test_run_executions_api + # Configure log output for this run before the websocket starts receiving log + # records. Without this, loguru's default stderr sink stays active and every + # raw log record gets printed straight to the terminal instead of being routed + # to the log file / streaming viewer, interleaving with the tree output below. + log_path = test_logging.configure_logger_for_run(title=new_execution.title, enable_log_streaming=enable_streaming) + test_logging.set_download_run_id(new_execution.id) + header = colorize_header("Starting Test run") title = colorize_key_value("Title", new_execution.title) test_run_id = colorize_key_value("ID", str(new_execution.id)) click.echo("") click.echo(f"{header}:\n- {title}\n- {test_run_id}\n") + log_stream_url = test_logging.get_log_stream_url() + if log_stream_url: + border = click.style("═" * 60, fg="cyan", bold=True) + click.echo(border) + click.echo(click.style(" 📋 Real-Time Log Viewer Available", fg="cyan", bold=True)) + click.echo(border) + click.echo(click.style(" View logs in real-time at:", fg="bright_white", bold=True)) + click.echo(" " + click.style(f"{log_stream_url}", fg="cyan", bold=True, underline=True)) + click.echo(click.style(" Logs will stream automatically as tests execute", fg="bright_white")) + click.echo(border) + click.echo("") + socket = TestRunSocket(new_execution) socket_task = asyncio.create_task(socket.connect_websocket()) try: @@ -653,8 +687,12 @@ async def __start_and_stream_repeated_execution( await _cancel_socket_task(socket_task) raise CLIError(_timeout_or_connection_error(e, f"start repeated test run execution '{new_execution.id}'")) - socket.run = started_execution - await socket_task + try: + socket.run = started_execution + await socket_task + click.echo(colorize_key_value("Log output in", italic(log_path))) + finally: + test_logging.stop_log_streaming() async def _cancel_socket_task(socket_task: "asyncio.Task[None]") -> None: From 825ca01475598acc51f352fb878c9182e1caf83f Mon Sep 17 00:00:00 2001 From: Romulo Quidute Filho <rquidute@apple.com> Date: Thu, 10 Sep 2026 23:35:23 +0000 Subject: [PATCH 06/10] Updated th_cli/api_lib_autogen/models.py --- th_cli/api_lib_autogen/models.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/th_cli/api_lib_autogen/models.py b/th_cli/api_lib_autogen/models.py index 774f704..c030c82 100644 --- a/th_cli/api_lib_autogen/models.py +++ b/th_cli/api_lib_autogen/models.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: -# filename: tmpktlfxzkc.json -# timestamp: 2026-09-01T11:49:15+00:00 +# filename: tmph7a30dit.json +# timestamp: 2026-09-10T23:33:56+00:00 from __future__ import annotations @@ -82,6 +82,7 @@ class PICSItem(BaseModel): class THConfig(BaseModel): prompt_timeout_seconds: Annotated[int | None, Field(title="Prompt Timeout Seconds")] = 60 enable_realtime_python_test_logs: Annotated[bool | None, Field(title="Enable Realtime Python Test Logs")] = None + enable_container_logs: Annotated[bool | None, Field(title="Enable Container Logs")] = None class PublicId(RootModel[str]): From 22d6d4e4f579f0ba86769037b39ec219d4f30a99 Mon Sep 17 00:00:00 2001 From: Romulo Quidute Filho <rquidute@apple.com> Date: Thu, 10 Sep 2026 20:41:27 -0300 Subject: [PATCH 07/10] Document --no-color/--no-streaming flags on test-run-execution repeat --- README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 7fe4bd5..d116886 100644 --- a/README.md +++ b/README.md @@ -153,7 +153,9 @@ same way `run-tests` does (and the frontend's "Repeat" action does): streaming l progress and forwarding any user prompts to this terminal. Use `--title` to override the generated title (defaults to the original title with an updated timestamp; the backend always appends a timestamp regardless). Add `--no-start` to only create the repeated -execution without starting it. +execution without starting it. As with `run-tests`, `--no-color` disables colored output +and `--no-streaming` disables the real-time web log viewer (enabled by default) for the +new execution. ### Test Run Execution Export From d68a810013d5ec9559fa1c73a014f099d2feea60 Mon Sep 17 00:00:00 2001 From: Romulo Quidute Filho <rquidute@apple.com> Date: Thu, 10 Sep 2026 20:59:15 -0300 Subject: [PATCH 08/10] Remove --no-start from test-run-execution repeat The 'repeat' command always started and streamed the new execution by default; --no-start's only purpose was to opt out of that, which doesn't fit the command's actual use case (repeat implies re-running). Removed the flag, its help text, and its test coverage. --- README.md | 7 +++-- ...test_run_execution_repeat_export_import.py | 26 ++----------------- th_cli/commands/test_run_execution.py | 17 ++---------- 3 files changed, 7 insertions(+), 43 deletions(-) diff --git a/README.md b/README.md index d116886..b3891e8 100644 --- a/README.md +++ b/README.md @@ -152,10 +152,9 @@ the same selected tests and config as an existing one, then start it and attach same way `run-tests` does (and the frontend's "Repeat" action does): streaming live test progress and forwarding any user prompts to this terminal. Use `--title` to override the generated title (defaults to the original title with an updated timestamp; the backend -always appends a timestamp regardless). Add `--no-start` to only create the repeated -execution without starting it. As with `run-tests`, `--no-color` disables colored output -and `--no-streaming` disables the real-time web log viewer (enabled by default) for the -new execution. +always appends a timestamp regardless). As with `run-tests`, `--no-color` disables colored +output and `--no-streaming` disables the real-time web log viewer (enabled by default) for +the new execution. ### Test Run Execution Export diff --git a/tests/test_test_run_execution_repeat_export_import.py b/tests/test_test_run_execution_repeat_export_import.py index ff8c7ed..3ebe474 100644 --- a/tests/test_test_run_execution_repeat_export_import.py +++ b/tests/test_test_run_execution_repeat_export_import.py @@ -125,29 +125,6 @@ def test_repeat_with_custom_title( id=1, title="Custom Title" ) - def test_repeat_no_start( - self, - cli_runner: CliRunner, - mock_async_apis: Mock, - mock_api_client: Mock, - sample_test_run_execution: api_models.TestRunExecutionWithChildren, - ) -> None: - """--no-start only creates the repeated execution, without starting or attaching to it.""" - api = mock_async_apis.test_run_executions_api - api.repeat_test_run_execution_api_v1_test_run_executions__id__repeat_post.return_value = ( - sample_test_run_execution - ) - - with ( - patch("th_cli.commands.test_run_execution.get_client", return_value=mock_api_client), - patch("th_cli.commands.test_run_execution.AsyncApis", return_value=mock_async_apis), - ): - result = cli_runner.invoke(test_run_execution, ["repeat", "--id", "1", "--no-start"]) - - assert result.exit_code == 0 - assert "Starting Test run" not in result.output - api.start_test_run_execution_api_v1_test_run_executions__id__start_post.assert_not_called() - def test_repeat_start_api_error( self, cli_runner: CliRunner, @@ -323,7 +300,8 @@ def test_repeat_help_message(self, cli_runner: CliRunner) -> None: assert result.exit_code == 0 assert "--id" in result.output assert "--title" in result.output - assert "--no-start" in result.output + assert "--no-color" in result.output + assert "--no-streaming" in result.output @pytest.mark.unit diff --git a/th_cli/commands/test_run_execution.py b/th_cli/commands/test_run_execution.py index 3b6a9e4..21b2c86 100644 --- a/th_cli/commands/test_run_execution.py +++ b/th_cli/commands/test_run_execution.py @@ -294,17 +294,6 @@ def pics_export(id: int, output_file: str) -> None: "appends an updated timestamp regardless" ), ) -@click.option( - "--no-start", - is_flag=True, - default=False, - help=colorize_help( - "Only create the repeated execution without starting it. By default, 'repeat' starts " - "the new execution right away and attaches to it the same way 'run-tests' does (and " - "the frontend's 'Repeat' action does): streaming live progress and forwarding any " - "user prompts to this terminal" - ), -) @click.option( "--no-color", is_flag=True, @@ -316,7 +305,7 @@ def pics_export(id: int, output_file: str) -> None: help=colorize_help("Disable real-time log streaming via web browser (enabled by default)."), ) @async_cmd -async def repeat(id: int, title: str | None, no_start: bool, no_color: bool, no_streaming: bool) -> None: +async def repeat(id: int, title: str | None, no_color: bool, no_streaming: bool) -> None: if no_color: set_colors_enabled(False) @@ -326,9 +315,7 @@ async def repeat(id: int, title: str | None, no_start: bool, no_color: bool, no_ client._async_client.timeout = TEST_RUN_EXECUTION_IO_TIMEOUT async_apis = AsyncApis(client) new_execution = await __repeat_test_run_execution(async_apis, id, title) - - if not no_start: - await __start_and_stream_repeated_execution(async_apis, new_execution, enable_streaming=not no_streaming) + await __start_and_stream_repeated_execution(async_apis, new_execution, enable_streaming=not no_streaming) except CLIError: raise # Re-raise CLI Errors as-is finally: From 0b9dad2ffd24c5543f829c1c0a0c8a0c1860be06 Mon Sep 17 00:00:00 2001 From: Romulo Quidute Filho <rquidute@apple.com> Date: Fri, 11 Sep 2026 09:58:28 -0300 Subject: [PATCH 09/10] Fix repeat: pass execution_config to socket, stop log streaming on all exit paths Addresses CodeRabbit review comments on PR #119: - TestRunSocket(new_execution) now passes project_config_dict so __display_manual_pairing_code() can access pairing/CHIP server info during a repeated run. - test_logging.stop_log_streaming() is now called on every exit path of __start_and_stream_repeated_execution, including when the start call fails (409, other API errors, timeouts). Previously the LogStreamHandler's daemon HTTP server was left running in those cases. - Chain the re-raised CLIErrors with 'from e' (Ruff B904). - Repeat command tests now patch test_logging instead of starting a real LogStreamHandler, and assert stop_log_streaming() is called on both success and failure paths. Added a test for --no-streaming. --- ...test_run_execution_repeat_export_import.py | 66 +++++++++++++++++-- th_cli/commands/test_run_execution.py | 30 +++++---- 2 files changed, 78 insertions(+), 18 deletions(-) diff --git a/tests/test_test_run_execution_repeat_export_import.py b/tests/test_test_run_execution_repeat_export_import.py index 3ebe474..19a89aa 100644 --- a/tests/test_test_run_execution_repeat_export_import.py +++ b/tests/test_test_run_execution_repeat_export_import.py @@ -49,12 +49,22 @@ def _make_exported_execution(title: str = "My Execution!") -> api_models.Exporte class TestRepeatCommand: """Test cases for the `test-run-execution repeat` command.""" + @pytest.fixture(autouse=True) + def mock_test_logging(self): + """Patch th_cli.test_run.logging so tests don't start a real LogStreamHandler + (and its daemon HTTP server) for every invocation.""" + with patch("th_cli.commands.test_run_execution.test_logging") as mock_logging: + mock_logging.configure_logger_for_run.return_value = "/tmp/test_run.log" + mock_logging.get_log_stream_url.return_value = None + yield mock_logging + def test_repeat_success( self, cli_runner: CliRunner, mock_async_apis: Mock, mock_api_client: Mock, sample_test_run_execution: api_models.TestRunExecutionWithChildren, + mock_test_logging: Mock, ) -> None: """By default, repeat creates the new execution, starts it, and attaches to it the same way `run-tests` does (and the frontend's 'Repeat' action does): streaming live @@ -88,9 +98,15 @@ def test_repeat_success( api.start_test_run_execution_api_v1_test_run_executions__id__start_post.assert_called_once_with( id=sample_test_run_execution.id ) - mock_socket_class.assert_called_once_with(sample_test_run_execution) + mock_test_logging.configure_logger_for_run.assert_called_once_with( + title=sample_test_run_execution.title, enable_log_streaming=True + ) + mock_socket_class.assert_called_once_with( + sample_test_run_execution, project_config_dict=sample_test_run_execution.execution_config or {} + ) mock_socket.connect_websocket.assert_called_once() assert mock_socket.run == sample_test_run_execution + mock_test_logging.stop_log_streaming.assert_called_once() mock_api_client.aclose.assert_called_once() def test_repeat_with_custom_title( @@ -131,8 +147,10 @@ def test_repeat_start_api_error( mock_async_apis: Mock, mock_api_client: Mock, sample_test_run_execution: api_models.TestRunExecutionWithChildren, + mock_test_logging: Mock, ) -> None: - """A failure to start the repeated execution is surfaced via the standard error-handling path.""" + """A failure to start the repeated execution is surfaced via the standard error-handling path, + and the log streaming server started for the run is still stopped.""" api = mock_async_apis.test_run_executions_api api.repeat_test_run_execution_api_v1_test_run_executions__id__repeat_post.return_value = ( sample_test_run_execution @@ -157,6 +175,7 @@ def test_repeat_start_api_error( f"Failed to start repeated test run execution '{sample_test_run_execution.id}' " "(Status: 500) - Internal Server Error" in result.output ) + mock_test_logging.stop_log_streaming.assert_called_once() def test_repeat_start_conflict( self, @@ -164,8 +183,10 @@ def test_repeat_start_conflict( mock_async_apis: Mock, mock_api_client: Mock, sample_test_run_execution: api_models.TestRunExecutionWithChildren, + mock_test_logging: Mock, ) -> None: - """A 409 (e.g. test engine busy) makes clear the execution was still created.""" + """A 409 (e.g. test engine busy) makes clear the execution was still created, and the + log streaming server started for the run is still stopped.""" api = mock_async_apis.test_run_executions_api api.repeat_test_run_execution_api_v1_test_run_executions__id__repeat_post.return_value = ( sample_test_run_execution @@ -188,6 +209,7 @@ def test_repeat_start_conflict( assert result.exit_code == 1 assert f"Execution {sample_test_run_execution.id} was created but could not be started" in result.output assert "Test Engine is busy." in result.output + mock_test_logging.stop_log_streaming.assert_called_once() def test_repeat_start_timeout( self, @@ -195,9 +217,11 @@ def test_repeat_start_timeout( mock_async_apis: Mock, mock_api_client: Mock, sample_test_run_execution: api_models.TestRunExecutionWithChildren, + mock_test_logging: Mock, ) -> None: """A timeout while starting the repeated execution is surfaced as a clean, readable - error instead of a raw traceback.""" + error instead of a raw traceback, and the log streaming server started for the run + is still stopped.""" api = mock_async_apis.test_run_executions_api api.repeat_test_run_execution_api_v1_test_run_executions__id__repeat_post.return_value = ( sample_test_run_execution @@ -219,6 +243,7 @@ def test_repeat_start_timeout( assert result.exit_code == 1 assert "Timed out waiting for the server" in result.output + mock_test_logging.stop_log_streaming.assert_called_once() def test_repeat_not_found(self, cli_runner: CliRunner, mock_async_apis: Mock, mock_api_client: Mock) -> None: """A 404 from the API is surfaced as a clear 'not found' error, and nothing is started.""" @@ -293,6 +318,39 @@ def test_repeat_requires_id(self, cli_runner: CliRunner) -> None: assert result.exit_code != 0 assert "Missing option" in result.output or "--id" in result.output + def test_repeat_no_streaming_disables_log_viewer( + self, + cli_runner: CliRunner, + mock_async_apis: Mock, + mock_api_client: Mock, + sample_test_run_execution: api_models.TestRunExecutionWithChildren, + mock_test_logging: Mock, + ) -> None: + """--no-streaming disables the real-time web log viewer for the new execution.""" + api = mock_async_apis.test_run_executions_api + api.repeat_test_run_execution_api_v1_test_run_executions__id__repeat_post.return_value = ( + sample_test_run_execution + ) + api.start_test_run_execution_api_v1_test_run_executions__id__start_post.return_value = ( + sample_test_run_execution + ) + + with ( + patch("th_cli.commands.test_run_execution.get_client", return_value=mock_api_client), + patch("th_cli.commands.test_run_execution.AsyncApis", return_value=mock_async_apis), + patch("th_cli.commands.test_run_execution.TestRunSocket") as mock_socket_class, + ): + mock_socket = Mock() + mock_socket.connect_websocket = AsyncMock() + mock_socket_class.return_value = mock_socket + + result = cli_runner.invoke(test_run_execution, ["repeat", "--id", "1", "--no-streaming"]) + + assert result.exit_code == 0 + mock_test_logging.configure_logger_for_run.assert_called_once_with( + title=sample_test_run_execution.title, enable_log_streaming=False + ) + def test_repeat_help_message(self, cli_runner: CliRunner) -> None: """Test the help message for the repeat command.""" result = cli_runner.invoke(test_run_execution, ["repeat", "--help"]) diff --git a/th_cli/commands/test_run_execution.py b/th_cli/commands/test_run_execution.py index 21b2c86..f28401a 100644 --- a/th_cli/commands/test_run_execution.py +++ b/th_cli/commands/test_run_execution.py @@ -657,24 +657,26 @@ async def __start_and_stream_repeated_execution( click.echo(border) click.echo("") - socket = TestRunSocket(new_execution) + socket = TestRunSocket(new_execution, project_config_dict=new_execution.execution_config or {}) socket_task = asyncio.create_task(socket.connect_websocket()) try: - start_call = test_run_execution_api.start_test_run_execution_api_v1_test_run_executions__id__start_post - started_execution = await start_call(id=new_execution.id) - except UnexpectedResponse as e: - await _cancel_socket_task(socket_task) - if e.status_code == 409: + try: + start_call = test_run_execution_api.start_test_run_execution_api_v1_test_run_executions__id__start_post + started_execution = await start_call(id=new_execution.id) + except UnexpectedResponse as e: + await _cancel_socket_task(socket_task) + if e.status_code == 409: + raise CLIError( + f"Execution {new_execution.id} was created but could not be started: " + f"{_extract_error_detail(e)}" + ) from e + handle_api_error(e, f"start repeated test run execution '{new_execution.id}'") + except ResponseHandlingException as e: + await _cancel_socket_task(socket_task) raise CLIError( - f"Execution {new_execution.id} was created but could not be started: " - f"{_extract_error_detail(e)}" - ) - handle_api_error(e, f"start repeated test run execution '{new_execution.id}'") - except ResponseHandlingException as e: - await _cancel_socket_task(socket_task) - raise CLIError(_timeout_or_connection_error(e, f"start repeated test run execution '{new_execution.id}'")) + _timeout_or_connection_error(e, f"start repeated test run execution '{new_execution.id}'") + ) from e - try: socket.run = started_execution await socket_task click.echo(colorize_key_value("Log output in", italic(log_path))) From 00aeddd42b29fba238ac7e4c9e755152ce071d7e Mon Sep 17 00:00:00 2001 From: Romulo Quidute Filho <rquidute@apple.com> Date: Fri, 11 Sep 2026 11:53:38 -0300 Subject: [PATCH 10/10] Fix repeat: add broad exception fallback, avoid private-attribute timeout hack Addresses human review comments from @oxesoft on PR #119: - repeat() now mirrors run_tests()'s 'except Exception as e: raise CLIError(...)' fallback around the start/stream/socket flow. Without it, a TestRunSocket.connect_websocket() failure that isn't ConnectionClosedOK (e.g. ConnectionRefusedError if the backend is unreachable) propagated as a raw Python traceback instead of a clean CLIError, since it's neither CLIError/UnexpectedResponse/ ResponseHandlingException nor caught by async_cmd's bare asyncio.run(). - get_client() now accepts an optional 'timeout' kwarg forwarded to ApiClient (which already forwards **kwargs to httpx.AsyncClient), replacing the 'client._async_client.timeout = ...' private-attribute hack duplicated across repeat/export/import. - Added tests for get_client(timeout=...) and for the new repeat() exception fallback covering a connect_websocket failure. --- tests/test_client.py | 27 ++++++++++++++ ...test_run_execution_repeat_export_import.py | 36 +++++++++++++++++++ th_cli/client.py | 15 ++++++-- th_cli/commands/test_run_execution.py | 11 +++--- 4 files changed, 80 insertions(+), 9 deletions(-) diff --git a/tests/test_client.py b/tests/test_client.py index ced7a92..b336577 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -86,6 +86,33 @@ def capture(**kwargs): assert captured_host[0].startswith("http://") + def test_timeout_not_passed_when_omitted(self): + """When no timeout is given, ApiClient is not passed a 'timeout' kwarg (httpx's + own default applies).""" + from th_cli.client import get_client + + with patch("th_cli.client.ApiClient") as mock_cls: + mock_cls.return_value = MagicMock() + get_client() + + assert "timeout" not in mock_cls.call_args.kwargs + + def test_timeout_forwarded_to_api_client(self): + """An explicit timeout is forwarded to ApiClient (and from there to the + underlying httpx.AsyncClient), instead of callers having to poke + client._async_client.timeout after construction.""" + from httpx import Timeout + + from th_cli.client import get_client + + custom_timeout = Timeout(120.0, connect=10.0) + + with patch("th_cli.client.ApiClient") as mock_cls: + mock_cls.return_value = MagicMock() + get_client(timeout=custom_timeout) + + assert mock_cls.call_args.kwargs.get("timeout") == custom_timeout + # --------------------------------------------------------------------------- # Module-level client fallback diff --git a/tests/test_test_run_execution_repeat_export_import.py b/tests/test_test_run_execution_repeat_export_import.py index 19a89aa..7239f01 100644 --- a/tests/test_test_run_execution_repeat_export_import.py +++ b/tests/test_test_run_execution_repeat_export_import.py @@ -245,6 +245,42 @@ def test_repeat_start_timeout( assert "Timed out waiting for the server" in result.output mock_test_logging.stop_log_streaming.assert_called_once() + def test_repeat_websocket_connect_failure( + self, + cli_runner: CliRunner, + mock_async_apis: Mock, + mock_api_client: Mock, + sample_test_run_execution: api_models.TestRunExecutionWithChildren, + mock_test_logging: Mock, + ) -> None: + """An unexpected failure while connecting the websocket (e.g. the backend refuses the + connection) is surfaced as a clean CLIError instead of a raw Python traceback, and the + log streaming server started for the run is still stopped.""" + api = mock_async_apis.test_run_executions_api + api.repeat_test_run_execution_api_v1_test_run_executions__id__repeat_post.return_value = ( + sample_test_run_execution + ) + api.start_test_run_execution_api_v1_test_run_executions__id__start_post.return_value = ( + sample_test_run_execution + ) + + with ( + patch("th_cli.commands.test_run_execution.get_client", return_value=mock_api_client), + patch("th_cli.commands.test_run_execution.AsyncApis", return_value=mock_async_apis), + patch("th_cli.commands.test_run_execution.TestRunSocket") as mock_socket_class, + ): + mock_socket = Mock() + mock_socket.connect_websocket = AsyncMock(side_effect=ConnectionRefusedError("connection refused")) + mock_socket_class.return_value = mock_socket + + result = cli_runner.invoke(test_run_execution, ["repeat", "--id", "1"]) + + assert result.exit_code == 1 + assert "Unexpected error during repeated test execution" in result.output + assert "Traceback" not in result.output + mock_test_logging.stop_log_streaming.assert_called_once() + mock_api_client.aclose.assert_called_once() + def test_repeat_not_found(self, cli_runner: CliRunner, mock_async_apis: Mock, mock_api_client: Mock) -> None: """A 404 from the API is surfaced as a clear 'not found' error, and nothing is started.""" api = mock_async_apis.test_run_executions_api diff --git a/th_cli/client.py b/th_cli/client.py index 9c0af88..cc18bfa 100644 --- a/th_cli/client.py +++ b/th_cli/client.py @@ -13,15 +13,24 @@ # See the License for the specific language governing permissions and # limitations under the License. # +from httpx import Timeout + from th_cli.api_lib_autogen.api_client import ApiClient from th_cli.config import config from th_cli.exceptions import ConfigurationError -def get_client() -> ApiClient: - """Get API client with proper error handling.""" +def get_client(timeout: Timeout | float | None = None) -> ApiClient: + """Get API client with proper error handling. + + Args: + timeout: Optional httpx timeout to use for this client's requests, + forwarded to the underlying httpx.AsyncClient. Defaults to + httpx's own default if not provided. + """ try: - return ApiClient(host=f"http://{config.hostname}") + kwargs = {} if timeout is None else {"timeout": timeout} + return ApiClient(host=f"http://{config.hostname}", **kwargs) except Exception as e: raise ConfigurationError( f"Could not connect to API server at {config.hostname}. " diff --git a/th_cli/commands/test_run_execution.py b/th_cli/commands/test_run_execution.py index f28401a..b807e15 100644 --- a/th_cli/commands/test_run_execution.py +++ b/th_cli/commands/test_run_execution.py @@ -311,13 +311,14 @@ async def repeat(id: int, title: str | None, no_color: bool, no_streaming: bool) client = None try: - client = get_client() - client._async_client.timeout = TEST_RUN_EXECUTION_IO_TIMEOUT + client = get_client(timeout=TEST_RUN_EXECUTION_IO_TIMEOUT) async_apis = AsyncApis(client) new_execution = await __repeat_test_run_execution(async_apis, id, title) await __start_and_stream_repeated_execution(async_apis, new_execution, enable_streaming=not no_streaming) except CLIError: raise # Re-raise CLI Errors as-is + except Exception as e: + raise CLIError(f"Unexpected error during repeated test execution: {e}") finally: if client: await client.aclose() @@ -344,8 +345,7 @@ async def repeat(id: int, title: str | None, no_color: bool, no_streaming: bool) ) def export(id: int, output_file: str | None) -> None: try: - with closing(get_client()) as client: - client._async_client.timeout = TEST_RUN_EXECUTION_IO_TIMEOUT + with closing(get_client(timeout=TEST_RUN_EXECUTION_IO_TIMEOUT)) as client: sync_apis = SyncApis(client) __export_test_run_execution(sync_apis, id, output_file) @@ -374,8 +374,7 @@ def export(id: int, output_file: str | None) -> None: ) def import_execution(file: str, project_id: int) -> None: try: - with closing(get_client()) as client: - client._async_client.timeout = TEST_RUN_EXECUTION_IO_TIMEOUT + with closing(get_client(timeout=TEST_RUN_EXECUTION_IO_TIMEOUT)) as client: sync_apis = SyncApis(client) __import_test_run_execution(sync_apis, file, project_id)