diff --git a/tests/test_run/test_websocket_additional.py b/tests/test_run/test_websocket_additional.py index c95a858..b45335f 100644 --- a/tests/test_run/test_websocket_additional.py +++ b/tests/test_run/test_websocket_additional.py @@ -107,30 +107,31 @@ async def test_echoes_state_text(self): assert "PASSED" in output.upper() or "passed" in output.lower() @pytest.mark.asyncio - async def test_displays_chip_server_info_on_executing(self): + async def test_starts_chip_server_watcher_on_executing(self): s = _make_socket(project_config={ "dut_config": {"discriminator": 1234, "setup_code": 20202021} }) update = TestRunUpdate(state=SharedTestStateEnum.EXECUTING, test_run_execution_id=1) - with patch.object(s, "_TestRunSocket__display_manual_pairing_code", new_callable=AsyncMock) as mock_display: + with patch.object(s, "_TestRunSocket__watch_chip_server_info", new_callable=AsyncMock) as mock_watch: with patch("th_cli.test_run.websocket.click.echo"): await s._TestRunSocket__log_test_run_update(update) + await s._chip_server_watcher - mock_display.assert_called_once() + mock_watch.assert_called_once() assert s._chip_server_info_displayed is True @pytest.mark.asyncio - async def test_does_not_display_chip_info_twice(self): + async def test_does_not_start_chip_server_watcher_twice(self): s = _make_socket() s._chip_server_info_displayed = True update = TestRunUpdate(state=SharedTestStateEnum.EXECUTING, test_run_execution_id=1) - with patch.object(s, "_TestRunSocket__display_manual_pairing_code", new_callable=AsyncMock) as mock_display: + with patch.object(s, "_TestRunSocket__watch_chip_server_info", new_callable=AsyncMock) as mock_watch: with patch("th_cli.test_run.websocket.click.echo"): await s._TestRunSocket__log_test_run_update(update) - mock_display.assert_not_called() + mock_watch.assert_not_called() # --------------------------------------------------------------------------- @@ -176,31 +177,114 @@ async def test_handles_api_exception_gracefully(self): await s._TestRunSocket__display_manual_pairing_code() # Must not raise - @pytest.mark.asyncio - async def test_calls_chip_server_info_api(self): - s = _make_socket(project_config={ - "dut_config": {"discriminator": 1234, "setup_code": 20202021} - }) + @staticmethod + def _patch_chip_info_api(responses): mock_client = MagicMock() mock_client.aclose = AsyncMock() - mock_chip_info = MagicMock() - mock_chip_info.node_id_hex = "0x0001" - mock_chip_info.manual_pairing_code = "34970112332" - - mock_api = AsyncMock() - mock_api.get_chip_server_info_api_v1_test_run_executions_chip_server_info_get = AsyncMock( - return_value=mock_chip_info - ) + get_info = AsyncMock(side_effect=responses) + mock_api = MagicMock() + mock_api.get_chip_server_info_api_v1_test_run_executions_chip_server_info_get = get_info mock_async_apis = MagicMock() mock_async_apis.test_run_executions_api = mock_api - + return mock_client, mock_async_apis, get_info + + @staticmethod + def _chip_info(node_id=1, started=True, manual_code=None): + info = MagicMock() + info.node_id = node_id + info.node_id_hex = hex(node_id) + info.server_started = started + info.manual_pairing_code = manual_code + return info + + async def _display(self, s, mock_client, mock_async_apis): with patch("th_cli.test_run.websocket.get_client", return_value=mock_client): with patch("th_cli.test_run.websocket.AsyncApis", return_value=mock_async_apis): - with patch("th_cli.test_run.websocket.click.echo"): + with patch("th_cli.test_run.websocket.click.echo") as mock_echo: await s._TestRunSocket__display_manual_pairing_code() + return " ".join(str(a) for call in mock_echo.call_args_list for a in call[0]) + + @pytest.mark.asyncio + async def test_does_not_display_node_id_before_server_started(self): + s = _make_socket(project_config={"dut_config": {"discriminator": 1234, "setup_code": 20202021}}) + client, apis, get_info = self._patch_chip_info_api([self._chip_info(started=False)]) + + output = await self._display(s, client, apis) + + assert output == "" + get_info.assert_called_once_with() + assert s._displayed_node_id is None + client.aclose.assert_called_once() + + @pytest.mark.asyncio + async def test_displays_node_id_and_pairing_code_once_server_started(self): + s = _make_socket(project_config={"dut_config": {"discriminator": 1234, "setup_code": 20202021}}) + client, apis, get_info = self._patch_chip_info_api( + [self._chip_info(node_id=0x10), self._chip_info(node_id=0x10, manual_code="34970112332")] + ) + + output = await self._display(s, client, apis) + + assert "0x10" in output + assert "34970112332" in output + assert s._displayed_node_id == 0x10 + assert get_info.call_count == 2 + assert get_info.call_args.kwargs["discriminator"] == 1234 + client.aclose.assert_called_once() + + @pytest.mark.asyncio + async def test_displays_node_id_when_backend_omits_server_started(self): + s = _make_socket(project_config={"dut_config": {"discriminator": 1234, "setup_code": 20202021}}) + client, apis, _ = self._patch_chip_info_api( + [self._chip_info(node_id=0x10, started=None), self._chip_info(node_id=0x10, started=None)] + ) + + output = await self._display(s, client, apis) + + assert "0x10" in output + assert s._displayed_node_id == 0x10 + + @pytest.mark.asyncio + async def test_does_not_redisplay_unchanged_node_id(self): + s = _make_socket(project_config={"dut_config": {"discriminator": 1234, "setup_code": 20202021}}) + s._displayed_node_id = 0x10 + client, apis, get_info = self._patch_chip_info_api([self._chip_info(node_id=0x10)]) + + output = await self._display(s, client, apis) + + assert output == "" + get_info.assert_called_once_with() + + @pytest.mark.asyncio + async def test_redisplays_when_node_id_changes(self): + s = _make_socket(project_config={"dut_config": {"discriminator": 1234, "setup_code": 20202021}}) + s._displayed_node_id = 0x10 + client, apis, _ = self._patch_chip_info_api([self._chip_info(node_id=0x20), self._chip_info(node_id=0x20)]) + + output = await self._display(s, client, apis) + + assert "0x20" in output + assert s._displayed_node_id == 0x20 + + +@pytest.mark.unit +class TestWatchChipServerInfo: + @pytest.mark.asyncio + async def test_polls_until_run_finished(self): + s = _make_socket() + calls = 0 + + async def fake_display(): + nonlocal calls + calls += 1 + if calls == 3: + s._run_finished = True + + with patch.object(s, "_TestRunSocket__display_manual_pairing_code", side_effect=fake_display): + with patch("th_cli.test_run.websocket.CHIP_SERVER_POLL_INTERVAL_S", 0): + await s._TestRunSocket__watch_chip_server_info() - mock_api.get_chip_server_info_api_v1_test_run_executions_chip_server_info_get.assert_called_once() - mock_client.aclose.assert_called_once() + assert calls == 3 # --------------------------------------------------------------------------- diff --git a/th_cli/api_lib_autogen/api/__init__.py b/th_cli/api_lib_autogen/api/__init__.py index dcfc013..08462b7 100644 --- a/th_cli/api_lib_autogen/api/__init__.py +++ b/th_cli/api_lib_autogen/api/__init__.py @@ -21,7 +21,6 @@ from th_cli.api_lib_autogen.api.test_collections_api import AsyncTestCollectionsApi, SyncTestCollectionsApi from th_cli.api_lib_autogen.api.test_run_configs_api import AsyncTestRunConfigsApi, SyncTestRunConfigsApi from th_cli.api_lib_autogen.api.test_run_executions_api import AsyncTestRunExecutionsApi, SyncTestRunExecutionsApi -from th_cli.api_lib_autogen.api.utils_api import AsyncUtilsApi, SyncUtilsApi from th_cli.api_lib_autogen.api.version_api import AsyncVersionApi, SyncVersionApi __all__ = [ @@ -43,9 +42,6 @@ "VersionApi", "AsyncVersionApi", "SyncVersionApi", - "UtilsApi", - "AsyncUtilsApi", - "SyncUtilsApi", "DevicesApi", "AsyncDevicesApi", "SyncDevicesApi", diff --git a/th_cli/api_lib_autogen/api/utils_api.py b/th_cli/api_lib_autogen/api/utils_api.py deleted file mode 100644 index 7bb9170..0000000 --- a/th_cli/api_lib_autogen/api/utils_api.py +++ /dev/null @@ -1,53 +0,0 @@ -# -# 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. -# -# flake8: noqa E501 -from asyncio import get_event_loop -from typing import IO, TYPE_CHECKING, Any, Coroutine - -from th_cli.api_lib_autogen import models as m - -if TYPE_CHECKING: - from th_cli.api_lib_autogen.api_client import ApiClient - - -class _UtilsApi: - def __init__(self, api_client: "ApiClient"): - self.api_client = api_client - - def _build_for_test_email_api_v1_utils_test_email__post(self, email_to: str) -> Coroutine[Any, Any, m.Msg]: - """ - Test Email - """ - query_params = {"email_to": str(email_to)} - - return self.api_client.request(type_=m.Msg, method="POST", url="/api/v1/utils/test-email/", params=query_params) - - -class AsyncUtilsApi(_UtilsApi): - async def test_email_api_v1_utils_test_email__post(self, email_to: str) -> m.Msg: - """ - Test Email - """ - return await self._build_for_test_email_api_v1_utils_test_email__post(email_to=email_to) - - -class SyncUtilsApi(_UtilsApi): - def test_email_api_v1_utils_test_email__post(self, email_to: str) -> m.Msg: - """ - Test Email - """ - coroutine = self._build_for_test_email_api_v1_utils_test_email__post(email_to=email_to) - return get_event_loop().run_until_complete(coroutine) diff --git a/th_cli/api_lib_autogen/api_client.py b/th_cli/api_lib_autogen/api_client.py index b7d385e..be21344 100644 --- a/th_cli/api_lib_autogen/api_client.py +++ b/th_cli/api_lib_autogen/api_client.py @@ -25,7 +25,6 @@ from th_cli.api_lib_autogen.api.test_collections_api import AsyncTestCollectionsApi, SyncTestCollectionsApi from th_cli.api_lib_autogen.api.test_run_configs_api import AsyncTestRunConfigsApi, SyncTestRunConfigsApi from th_cli.api_lib_autogen.api.test_run_executions_api import AsyncTestRunExecutionsApi, SyncTestRunExecutionsApi -from th_cli.api_lib_autogen.api.utils_api import AsyncUtilsApi, SyncUtilsApi from th_cli.api_lib_autogen.api.version_api import AsyncVersionApi, SyncVersionApi from th_cli.api_lib_autogen.exceptions import ResponseHandlingException, UnexpectedResponse @@ -42,7 +41,6 @@ def __init__(self, client: ClientT): self.test_run_executions_api = AsyncTestRunExecutionsApi(self.client) self.test_run_configs_api = AsyncTestRunConfigsApi(self.client) self.version_api = AsyncVersionApi(self.client) - self.utils_api = AsyncUtilsApi(self.client) self.devices_api = AsyncDevicesApi(self.client) @@ -56,7 +54,6 @@ def __init__(self, client: ClientT): self.test_run_executions_api = SyncTestRunExecutionsApi(self.client) self.test_run_configs_api = SyncTestRunConfigsApi(self.client) self.version_api = SyncVersionApi(self.client) - self.utils_api = SyncUtilsApi(self.client) self.devices_api = SyncDevicesApi(self.client) diff --git a/th_cli/api_lib_autogen/models.py b/th_cli/api_lib_autogen/models.py index c030c82..2c26b3b 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: tmph7a30dit.json -# timestamp: 2026-09-10T23:33:56+00:00 +# filename: tmp6b_blt9g.json +# timestamp: 2026-10-28T16:52:46+00:00 from __future__ import annotations @@ -38,13 +38,10 @@ class BodyUploadPicsApiV1ProjectsIdUploadPicsPut(BodyUploadFileApiV1TestRunExecu class ChipServerInfo(BaseModel): node_id: Annotated[int, Field(title="Node Id")] node_id_hex: Annotated[str, Field(title="Node Id Hex")] + server_started: Annotated[bool | None, Field(title="Server Started")] = False manual_pairing_code: Annotated[str | None, Field(title="Manual Pairing Code")] = None -class Msg(BaseModel): - msg: Annotated[str, Field(title="Msg")] - - class Name(RootModel[str]): root: Annotated[str, Field(title="Name")] @@ -83,6 +80,7 @@ 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 + sdk_container_apps_dir: Annotated[str | None, Field(title="Sdk Container Apps Dir")] = None class PublicId(RootModel[str]): diff --git a/th_cli/test_run/websocket.py b/th_cli/test_run/websocket.py index 8207693..3bd988e 100644 --- a/th_cli/test_run/websocket.py +++ b/th_cli/test_run/websocket.py @@ -93,6 +93,9 @@ # so the socket closes as soon as it arrives. DRAIN_TIMEOUT_S = 5.0 +# How often to check whether the CHIP server (re)started, so its Node ID is shown. +CHIP_SERVER_POLL_INTERVAL_S = 2.0 + # Yield to the event loop every N log records while processing one batch, so # a very large batch doesn't block the websocket read loop for its entire # duration. The backend sends up to LOG_RECORDS_BROADCAST_CHUNK_SIZE=200 @@ -131,6 +134,8 @@ def __init__( self.project_config_dict = project_config_dict or {} self.two_way_talk_handler = two_way_talk_handler self._chip_server_info_displayed = False + self._displayed_node_id: int | None = None + self._chip_server_watcher: asyncio.Task | None = None self._run_finished = False # From the terminal TestRunUpdate (see TestRunUpdate.logs_complete). self._logs_complete: bool | None = None @@ -238,6 +243,9 @@ async def connect_websocket(self) -> None: # benign handshake quirk. if not self._run_finished: incomplete_closure = True + finally: + if self._chip_server_watcher: + self._chip_server_watcher.cancel() if incomplete_closure: raise IncompleteTestRunError( @@ -300,7 +308,9 @@ async def __handle_test_update(self, update: TestUpdate) -> None: async def __log_test_run_update(self, update: TestRunUpdate) -> None: # Display CHIP server info when test run starts executing (SDK container already running) if update.state.value == "executing" and not self._chip_server_info_displayed: - await self.__display_manual_pairing_code() + # The chip server starts during suite setup, after the run is already + # executing, so watch for it instead of querying once. + self._chip_server_watcher = asyncio.create_task(self.__watch_chip_server_info()) self._chip_server_info_displayed = True handler = get_log_stream_handler() @@ -314,8 +324,19 @@ async def __log_test_run_update(self, update: TestRunUpdate) -> None: colored_state = colorize_state(update.state.value) click.echo(f"{test_run_text} {colored_state}") + async def __watch_chip_server_info(self) -> None: + """Display the CHIP server info each time the server (re)starts during the run.""" + while not self._run_finished: + await self.__display_manual_pairing_code() + await asyncio.sleep(CHIP_SERVER_POLL_INTERVAL_S) + async def __display_manual_pairing_code(self) -> None: - """Fetch and display manual pairing code after SDK container has started.""" + """Display the Node ID and manual pairing code once the CHIP server is up. + + The Node ID is regenerated every time the CHIP server starts, so it is only + displayed (again) when the server is started and the ID differs from the last + one shown. + """ try: # Extract device configuration dut_config = self.project_config_dict.get("dut_config", {}) @@ -335,13 +356,24 @@ async def __display_manual_pairing_code(self) -> None: client = get_client() try: test_run_api = AsyncApis(client).test_run_executions_api - chip_info = await test_run_api.get_chip_server_info_api_v1_test_run_executions_chip_server_info_get( + get_info = test_run_api.get_chip_server_info_api_v1_test_run_executions_chip_server_info_get + + # Cheap status check first: generating the pairing code runs a command + # in the SDK container, so only do it when there is something to show. + chip_info = await get_info() + # Only an explicit False means "not started": a backend that predates the + # server_started field omits it, and should still get the banner. + if chip_info.server_started is False or chip_info.node_id == self._displayed_node_id: + return + + chip_info = await get_info( discriminator=discriminator, setup_pin_code=setup_pin_code, version=version, vendor_id=vendor_id, product_id=product_id, ) + self._displayed_node_id = chip_info.node_id node_id = colorize_key_value("Node ID", chip_info.node_id_hex) click.echo("═══════════════════════════════════════════════════════") @@ -356,8 +388,8 @@ async def __display_manual_pairing_code(self) -> None: await client.aclose() except Exception as e: - logger.debug(f"Could not fetch manual pairing code: {e}") - # Don't fail the test run if we can't get the pairing code + logger.debug(f"Could not fetch CHIP server info: {e}") + # Don't fail the test run if we can't get the CHIP server info def __log_test_suite_update(self, update: TestSuiteUpdate) -> None: suite = self.__suite(update.test_suite_execution_index)