Skip to content
Merged
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
130 changes: 107 additions & 23 deletions tests/test_run/test_websocket_additional.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()


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


# ---------------------------------------------------------------------------
Expand Down
4 changes: 0 additions & 4 deletions th_cli/api_lib_autogen/api/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__ = [
Expand All @@ -43,9 +42,6 @@
"VersionApi",
"AsyncVersionApi",
"SyncVersionApi",
"UtilsApi",
"AsyncUtilsApi",
"SyncUtilsApi",
"DevicesApi",
"AsyncDevicesApi",
"SyncDevicesApi",
Expand Down
53 changes: 0 additions & 53 deletions th_cli/api_lib_autogen/api/utils_api.py

This file was deleted.

3 changes: 0 additions & 3 deletions th_cli/api_lib_autogen/api_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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)


Expand All @@ -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)


Expand Down
10 changes: 4 additions & 6 deletions th_cli/api_lib_autogen/models.py
Original file line number Diff line number Diff line change
@@ -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

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

Expand Down Expand Up @@ -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]):
Expand Down
42 changes: 37 additions & 5 deletions th_cli/test_run/websocket.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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()
Expand All @@ -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", {})
Expand All @@ -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("═══════════════════════════════════════════════════════")
Expand All @@ -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)
Expand Down
Loading