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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
156 changes: 156 additions & 0 deletions tests/test_rescan_tests.py
Original file line number Diff line number Diff line change
@@ -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
21 changes: 21 additions & 0 deletions th_cli/api_lib_autogen/api/test_collections_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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:
Expand All @@ -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)
2 changes: 2 additions & 0 deletions th_cli/commands/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -24,6 +25,7 @@
"abort_testing",
"available_tests",
"project",
"rescan_tests",
"run_tests",
"test_run_execution",
"test_runner_status",
Expand Down
73 changes: 73 additions & 0 deletions th_cli/commands/rescan_tests.py
Original file line number Diff line number Diff line change
@@ -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()
11 changes: 10 additions & 1 deletion th_cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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)
Expand Down
Loading