diff --git a/tests/test_rescan_tests.py b/tests/test_rescan_tests.py new file mode 100644 index 0000000..a61f905 --- /dev/null +++ b/tests/test_rescan_tests.py @@ -0,0 +1,156 @@ +# +# 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 rescan_tests command.""" + +from unittest.mock import Mock, patch + +import pytest +from click.testing import CliRunner +from httpx import ConnectError, ReadTimeout + +from th_cli.api_lib_autogen import models as api_models +from th_cli.api_lib_autogen.exceptions import ResponseHandlingException, UnexpectedResponse +from th_cli.commands.rescan_tests import rescan_tests +from th_cli.exceptions import ConfigurationError + + +@pytest.mark.unit +@pytest.mark.cli +class TestRescanTestsCommand: + """Test cases for the rescan_tests command.""" + + def test_rescan_tests_success( + self, + cli_runner: CliRunner, + mock_sync_apis: Mock, + mock_api_client: Mock, + sample_test_collections: api_models.TestCollections, + ) -> None: + """Test successful rescan of test collections.""" + # Arrange + api = mock_sync_apis.test_collections_api.rescan_test_collections_api_v1_test_collections_rescan_post + api.return_value = sample_test_collections + + with patch("th_cli.commands.rescan_tests.get_client", return_value=mock_api_client): + with patch("th_cli.commands.rescan_tests.SyncApis", return_value=mock_sync_apis): + # Act + result = cli_runner.invoke(rescan_tests) + + # Assert + assert result.exit_code == 0 + assert "Rescanned test collections successfully" in result.output + api.assert_called_once() + mock_api_client.close.assert_called_once() + + def test_rescan_tests_no_response( + self, + cli_runner: CliRunner, + mock_sync_apis: Mock, + mock_api_client: Mock, + ) -> None: + """Test handling of a None response from the server.""" + api = mock_sync_apis.test_collections_api.rescan_test_collections_api_v1_test_collections_rescan_post + api.return_value = None + + with patch("th_cli.commands.rescan_tests.get_client", return_value=mock_api_client): + with patch("th_cli.commands.rescan_tests.SyncApis", return_value=mock_sync_apis): + result = cli_runner.invoke(rescan_tests) + + assert result.exit_code == 1 + assert "Error: Server did not return test_collections" in result.output + mock_api_client.close.assert_called_once() + + def test_rescan_tests_configuration_error(self, cli_runner: CliRunner) -> None: + """Test rescan_tests with configuration error.""" + with patch( + "th_cli.commands.rescan_tests.get_client", side_effect=ConfigurationError("Could not connect to server") + ): + result = cli_runner.invoke(rescan_tests) + + assert result.exit_code == 1 + assert "Error: Could not connect to server" in result.output + + def test_rescan_tests_api_error(self, cli_runner: CliRunner, mock_sync_apis: Mock, mock_api_client: Mock) -> None: + """Test rescan_tests when the server reports the Test Engine is busy.""" + api_exception = UnexpectedResponse( + status_code=409, + content=b"Test Engine is busy.", + ) + api = mock_sync_apis.test_collections_api.rescan_test_collections_api_v1_test_collections_rescan_post + api.side_effect = api_exception + + with patch("th_cli.commands.rescan_tests.get_client", return_value=mock_api_client): + with patch("th_cli.commands.rescan_tests.SyncApis", return_value=mock_sync_apis): + result = cli_runner.invoke(rescan_tests) + + assert result.exit_code == 1 + assert "Error: Failed to rescan test collections (Status: 409) - Test Engine is busy." in result.output + mock_api_client.close.assert_called_once() + + def test_rescan_tests_timeout(self, cli_runner: CliRunner, mock_sync_apis: Mock, mock_api_client: Mock) -> None: + """A client-side timeout should not be reported as a failure: rescanning can + outlast the CLI's timeout while the backend keeps running it to completion.""" + api = mock_sync_apis.test_collections_api.rescan_test_collections_api_v1_test_collections_rescan_post + api.side_effect = ResponseHandlingException(ReadTimeout("timed out")) + + with patch("th_cli.commands.rescan_tests.get_client", return_value=mock_api_client): + with patch("th_cli.commands.rescan_tests.SyncApis", return_value=mock_sync_apis): + result = cli_runner.invoke(rescan_tests) + + assert result.exit_code == 0 + assert "Rescan request sent (backend may still be processing)" in result.output + mock_api_client.close.assert_called_once() + + def test_rescan_tests_response_handling_error_non_timeout( + self, cli_runner: CliRunner, mock_sync_apis: Mock, mock_api_client: Mock + ) -> None: + """A non-timeout response-handling error should still be reported as a failure.""" + api = mock_sync_apis.test_collections_api.rescan_test_collections_api_v1_test_collections_rescan_post + api.side_effect = ResponseHandlingException(ConnectError("connection refused")) + + with patch("th_cli.commands.rescan_tests.get_client", return_value=mock_api_client): + with patch("th_cli.commands.rescan_tests.SyncApis", return_value=mock_sync_apis): + result = cli_runner.invoke(rescan_tests) + + assert result.exit_code == 1 + assert "Could not rescan test collections" in result.output + mock_api_client.close.assert_called_once() + + def test_rescan_tests_generic_exception( + self, cli_runner: CliRunner, mock_sync_apis: Mock, mock_api_client: Mock + ) -> None: + """Test rescan_tests with an unexpected error.""" + api = mock_sync_apis.test_collections_api.rescan_test_collections_api_v1_test_collections_rescan_post + api.side_effect = Exception("Unexpected error") + + with patch("th_cli.commands.rescan_tests.get_client", return_value=mock_api_client): + with patch("th_cli.commands.rescan_tests.SyncApis", return_value=mock_sync_apis): + result = cli_runner.invoke(rescan_tests) + + assert result.exit_code == 1 + assert "Could not rescan test collections" in result.output + mock_api_client.close.assert_called_once() + + def test_rescan_tests_help_message(self, cli_runner: CliRunner) -> None: + """Test the help message for the rescan_tests command.""" + result = cli_runner.invoke(rescan_tests, ["--help"]) + + assert result.exit_code == 0 + # Click wraps the help text at a terminal-width-dependent column, so + # collapse whitespace/newlines before checking for the substring — + # otherwise the wrap point can land inside the expected text. + normalized_output = " ".join(result.output.split()) + assert "Re-run test collection discovery on the backend" in normalized_output diff --git a/th_cli/api_lib_autogen/api/test_collections_api.py b/th_cli/api_lib_autogen/api/test_collections_api.py index f3c414b..c439214 100644 --- a/th_cli/api_lib_autogen/api/test_collections_api.py +++ b/th_cli/api_lib_autogen/api/test_collections_api.py @@ -33,6 +33,14 @@ def _build_for_read_test_collections_api_v1_test_collections__get(self) -> Corou """ return self.api_client.request(type_=m.TestCollections, method="GET", url="/api/v1/test_collections/") + def _build_for_rescan_test_collections_api_v1_test_collections_rescan_post( + self, + ) -> Coroutine[Any, Any, m.TestCollections]: + """ + Rescan Test Collections + """ + return self.api_client.request(type_=m.TestCollections, method="POST", url="/api/v1/test_collections/rescan") + class AsyncTestCollectionsApi(_TestCollectionsApi): async def read_test_collections_api_v1_test_collections__get(self) -> m.TestCollections: @@ -41,6 +49,12 @@ async def read_test_collections_api_v1_test_collections__get(self) -> m.TestColl """ return await self._build_for_read_test_collections_api_v1_test_collections__get() + async def rescan_test_collections_api_v1_test_collections_rescan_post(self) -> m.TestCollections: + """ + Rescan Test Collections + """ + return await self._build_for_rescan_test_collections_api_v1_test_collections_rescan_post() + class SyncTestCollectionsApi(_TestCollectionsApi): def read_test_collections_api_v1_test_collections__get(self) -> m.TestCollections: @@ -49,3 +63,10 @@ def read_test_collections_api_v1_test_collections__get(self) -> m.TestCollection """ coroutine = self._build_for_read_test_collections_api_v1_test_collections__get() return get_event_loop().run_until_complete(coroutine) + + def rescan_test_collections_api_v1_test_collections_rescan_post(self) -> m.TestCollections: + """ + Rescan Test Collections + """ + coroutine = self._build_for_rescan_test_collections_api_v1_test_collections_rescan_post() + return get_event_loop().run_until_complete(coroutine) diff --git a/th_cli/commands/__init__.py b/th_cli/commands/__init__.py index 73ca737..1c6f3b9 100644 --- a/th_cli/commands/__init__.py +++ b/th_cli/commands/__init__.py @@ -16,6 +16,7 @@ from .abort_testing import abort_testing from .available_tests import available_tests from .project import project +from .rescan_tests import rescan_tests from .run_tests import run_tests from .test_run_execution import test_run_execution from .test_runner_status import test_runner_status @@ -24,6 +25,7 @@ "abort_testing", "available_tests", "project", + "rescan_tests", "run_tests", "test_run_execution", "test_runner_status", diff --git a/th_cli/commands/rescan_tests.py b/th_cli/commands/rescan_tests.py new file mode 100644 index 0000000..6990bab --- /dev/null +++ b/th_cli/commands/rescan_tests.py @@ -0,0 +1,73 @@ +# +# 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. +# + +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 ResponseHandlingException, UnexpectedResponse +from th_cli.client import get_client +from th_cli.colorize import colorize_cmd_help, colorize_help, colorize_success +from th_cli.exceptions import CLIError, handle_api_error + +# Rescanning regenerates the Python test JSON files via the SDK container, +# which can take significantly longer than httpx's 5s default read timeout. +RESCAN_TIMEOUT = Timeout(120.0, connect=10.0) # 120s total, 10s connect + + +@click.command( + short_help=colorize_help("Rescan available test collections"), + help=colorize_cmd_help( + "rescan_tests", + "Re-run test collection discovery on the backend, picking up newly " + "added or edited side-loaded test scripts without restarting it", + ), +) +def rescan_tests() -> None: + """Rescan available test collections""" + client = None + try: + client = get_client() + client._async_client.timeout = RESCAN_TIMEOUT + sync_apis: SyncApis = SyncApis(client) + test_collections = sync_apis.test_collections_api.rescan_test_collections_api_v1_test_collections_rescan_post() + + if test_collections is None: + raise CLIError("Server did not return test_collections") + + collection_count = len(test_collections.test_collections) + click.echo(colorize_success(f"Rescanned test collections successfully ({collection_count} found)")) + except CLIError: + raise # Re-raise CLI Errors as-is + except ResponseHandlingException as e: + # Rescanning can outlast even the extended timeout above (e.g. a + # slow SDK container pull). The backend keeps running the rescan to + # completion regardless of whether the CLI is still waiting on it. + if isinstance(e.error, TimeoutException): + click.echo(colorize_success("Rescan request sent (backend may still be processing)")) + else: + raise CLIError( + f"Could not rescan test collections: {e}. Please check if the API server is running and accessible." + ) + except UnexpectedResponse as e: + handle_api_error(e, "rescan test collections") + except Exception as e: + raise CLIError( + f"Could not rescan test collections: {e}. Please check if the API server is running and accessible." + ) + finally: + if client: + client.close() diff --git a/th_cli/main.py b/th_cli/main.py index fe71981..2e88f9a 100644 --- a/th_cli/main.py +++ b/th_cli/main.py @@ -18,7 +18,15 @@ import click from th_cli.colorize import colorize_cmd_help, colorize_error, colorize_key_value -from th_cli.commands import abort_testing, available_tests, project, run_tests, test_run_execution, test_runner_status +from th_cli.commands import ( + abort_testing, + available_tests, + project, + rescan_tests, + run_tests, + test_run_execution, + test_runner_status, +) from th_cli.utils import get_cli_sha, get_cli_version, get_versions @@ -52,6 +60,7 @@ def root() -> None: root.add_command(abort_testing) root.add_command(available_tests) root.add_command(project) +root.add_command(rescan_tests) root.add_command(run_tests) root.add_command(test_run_execution) root.add_command(test_runner_status)