diff --git a/tests/test_async_cmd.py b/tests/test_async_cmd.py
new file mode 100644
index 0000000..c6ed09d
--- /dev/null
+++ b/tests/test_async_cmd.py
@@ -0,0 +1,113 @@
+#
+# 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.
+#
+"""Unit tests for th_cli/async_cmd.py."""
+
+import asyncio
+
+import pytest
+
+from th_cli.async_cmd import async_cmd
+
+
+# ---------------------------------------------------------------------------
+# @async_cmd decorator
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.unit
+class TestAsyncCmd:
+ def test_wrapped_async_function_runs_synchronously(self):
+ @async_cmd
+ async def my_async_func():
+ return "result"
+
+ result = my_async_func()
+ assert result == "result"
+
+ def test_return_value_is_propagated(self):
+ @async_cmd
+ async def compute():
+ return 42
+
+ assert compute() == 42
+
+ def test_positional_arguments_forwarded(self):
+ @async_cmd
+ async def add(a, b):
+ return a + b
+
+ assert add(3, 4) == 7
+
+ def test_keyword_arguments_forwarded(self):
+ @async_cmd
+ async def greet(name, greeting="Hello"):
+ return f"{greeting}, {name}!"
+
+ result = greet(name="World", greeting="Hi")
+ assert result == "Hi, World!"
+
+ def test_wraps_preserves_function_name(self):
+ @async_cmd
+ async def original_name():
+ pass
+
+ assert original_name.__name__ == "original_name"
+
+ def test_wraps_preserves_docstring(self):
+ @async_cmd
+ async def documented():
+ """My docstring."""
+ pass
+
+ assert documented.__doc__ == "My docstring."
+
+ def test_async_operations_are_executed(self):
+ executed = []
+
+ @async_cmd
+ async def side_effects():
+ await asyncio.sleep(0) # real async operation
+ executed.append("done")
+
+ side_effects()
+ assert executed == ["done"]
+
+ def test_exception_propagates_from_async_body(self):
+ @async_cmd
+ async def raises():
+ raise ValueError("async error")
+
+ with pytest.raises(ValueError, match="async error"):
+ raises()
+
+ def test_none_return_value(self):
+ @async_cmd
+ async def returns_none():
+ return None
+
+ assert returns_none() is None
+
+ def test_can_decorate_multiple_functions_independently(self):
+ @async_cmd
+ async def func_a():
+ return "a"
+
+ @async_cmd
+ async def func_b():
+ return "b"
+
+ assert func_a() == "a"
+ assert func_b() == "b"
diff --git a/tests/test_client.py b/tests/test_client.py
new file mode 100644
index 0000000..ced7a92
--- /dev/null
+++ b/tests/test_client.py
@@ -0,0 +1,102 @@
+#
+# 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.
+#
+"""Unit tests for th_cli/client.py."""
+
+from unittest.mock import MagicMock, patch
+
+import pytest
+
+from th_cli.exceptions import ConfigurationError
+
+
+# ---------------------------------------------------------------------------
+# get_client()
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.unit
+class TestGetClient:
+ def test_returns_api_client_instance(self):
+ from th_cli.client import get_client
+ from th_cli.api_lib_autogen.api_client import ApiClient
+
+ with patch("th_cli.client.ApiClient") as mock_cls:
+ mock_instance = MagicMock(spec=ApiClient)
+ mock_cls.return_value = mock_instance
+ result = get_client()
+
+ assert result is mock_instance
+
+ def test_passes_correct_host_url(self):
+ from th_cli.client import get_client
+
+ with patch("th_cli.client.ApiClient") as mock_cls:
+ with patch("th_cli.client.config") as mock_config:
+ mock_config.hostname = "myserver"
+ mock_cls.return_value = MagicMock()
+ get_client()
+
+ call_kwargs = mock_cls.call_args
+ host_value = call_kwargs[1].get("host") or call_kwargs[0][0]
+ assert "myserver" in host_value
+
+ def test_raises_configuration_error_on_exception(self):
+ from th_cli.client import get_client
+
+ with patch("th_cli.client.ApiClient", side_effect=Exception("connection refused")):
+ with pytest.raises(ConfigurationError):
+ get_client()
+
+ def test_configuration_error_message_mentions_hostname(self):
+ from th_cli.client import get_client
+
+ with patch("th_cli.client.ApiClient", side_effect=Exception("boom")):
+ with patch("th_cli.client.config") as mock_config:
+ mock_config.hostname = "badhost"
+ with pytest.raises(ConfigurationError) as exc_info:
+ get_client()
+ assert "badhost" in exc_info.value.format_message()
+
+ def test_host_url_has_http_scheme(self):
+ from th_cli.client import get_client
+
+ captured_host = []
+
+ def capture(**kwargs):
+ captured_host.append(kwargs.get("host", ""))
+ return MagicMock()
+
+ with patch("th_cli.client.ApiClient", side_effect=capture):
+ with patch("th_cli.client.config") as mock_config:
+ mock_config.hostname = "somehost"
+ get_client()
+
+ assert captured_host[0].startswith("http://")
+
+
+# ---------------------------------------------------------------------------
+# Module-level client fallback
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.unit
+class TestModuleLevelClient:
+ def test_client_is_none_or_api_client(self):
+ """The module-level client should be either an ApiClient instance or None."""
+ import th_cli.client as client_mod
+ from th_cli.api_lib_autogen.api_client import ApiClient
+
+ assert client_mod.client is None or isinstance(client_mod.client, ApiClient)
diff --git a/tests/test_colorize.py b/tests/test_colorize.py
new file mode 100644
index 0000000..0d7546e
--- /dev/null
+++ b/tests/test_colorize.py
@@ -0,0 +1,438 @@
+#
+# 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.
+#
+"""Unit tests for th_cli/colorize.py."""
+
+import pytest
+
+from th_cli.colorize import (
+ ColorConfig,
+ HierarchyEnum,
+ TextTypeEnum,
+ colorize_cmd_help,
+ colorize_dump,
+ colorize_error,
+ colorize_header,
+ colorize_help,
+ colorize_hierarchy_prefix,
+ colorize_key_value,
+ colorize_runner_state,
+ colorize_state,
+ colorize_success,
+ colorize_warning,
+ italic,
+ set_colors_enabled,
+)
+from th_cli.api_lib_autogen.models import TestRunnerState, TestStateEnum
+
+
+# ---------------------------------------------------------------------------
+# ColorConfig — construction and environment variable
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.unit
+class TestColorConfigInit:
+ """Tests for ColorConfig.__init__ and the TH_CLI_NO_COLOR env var."""
+
+ def test_colors_enabled_by_default(self, monkeypatch):
+ monkeypatch.delenv("TH_CLI_NO_COLOR", raising=False)
+ cfg = ColorConfig()
+ assert cfg.colors_enabled is True
+
+ def test_colors_disabled_when_env_is_1(self, monkeypatch):
+ monkeypatch.setenv("TH_CLI_NO_COLOR", "1")
+ cfg = ColorConfig()
+ assert cfg.colors_enabled is False
+
+ def test_colors_disabled_when_env_is_true(self, monkeypatch):
+ monkeypatch.setenv("TH_CLI_NO_COLOR", "true")
+ cfg = ColorConfig()
+ assert cfg.colors_enabled is False
+
+ def test_colors_disabled_when_env_is_yes(self, monkeypatch):
+ monkeypatch.setenv("TH_CLI_NO_COLOR", "yes")
+ cfg = ColorConfig()
+ assert cfg.colors_enabled is False
+
+ def test_colors_enabled_when_env_is_0(self, monkeypatch):
+ monkeypatch.setenv("TH_CLI_NO_COLOR", "0")
+ cfg = ColorConfig()
+ assert cfg.colors_enabled is True
+
+ def test_colors_enabled_when_env_is_false(self, monkeypatch):
+ monkeypatch.setenv("TH_CLI_NO_COLOR", "false")
+ cfg = ColorConfig()
+ assert cfg.colors_enabled is True
+
+ def test_colors_enabled_property_returns_bool(self, monkeypatch):
+ monkeypatch.delenv("TH_CLI_NO_COLOR", raising=False)
+ cfg = ColorConfig()
+ assert isinstance(cfg.colors_enabled, bool)
+
+
+# ---------------------------------------------------------------------------
+# ColorConfig — get_state_color
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.unit
+class TestColorConfigGetStateColor:
+ def test_passed_state_returns_green(self):
+ cfg = ColorConfig()
+ assert cfg.get_state_color(TestStateEnum.passed.value) == "green"
+
+ def test_failed_state_returns_red(self):
+ cfg = ColorConfig()
+ assert cfg.get_state_color(TestStateEnum.failed.value) == "red"
+
+ def test_error_state_returns_red(self):
+ cfg = ColorConfig()
+ assert cfg.get_state_color(TestStateEnum.error.value) == "red"
+
+ def test_executing_state_returns_yellow(self):
+ cfg = ColorConfig()
+ assert cfg.get_state_color(TestStateEnum.executing.value) == "yellow"
+
+ def test_unknown_state_returns_white(self):
+ cfg = ColorConfig()
+ assert cfg.get_state_color("nonexistent_state") == "white"
+
+ def test_case_insensitive_lookup(self):
+ cfg = ColorConfig()
+ assert cfg.get_state_color("PASSED") == "green"
+
+
+# ---------------------------------------------------------------------------
+# ColorConfig — get_runner_state_color
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.unit
+class TestColorConfigGetRunnerStateColor:
+ def test_idle_state(self):
+ cfg = ColorConfig()
+ assert cfg.get_runner_state_color(TestRunnerState.idle.value) == "bright_black"
+
+ def test_ready_state(self):
+ cfg = ColorConfig()
+ assert cfg.get_runner_state_color(TestRunnerState.ready.value) == "green"
+
+ def test_loading_state(self):
+ cfg = ColorConfig()
+ assert cfg.get_runner_state_color(TestRunnerState.loading.value) == "yellow"
+
+ def test_running_state(self):
+ cfg = ColorConfig()
+ assert cfg.get_runner_state_color(TestRunnerState.running.value) == "red"
+
+ def test_unknown_runner_state_returns_white(self):
+ cfg = ColorConfig()
+ assert cfg.get_runner_state_color("unknown_runner") == "white"
+
+
+# ---------------------------------------------------------------------------
+# ColorConfig — get_text_color
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.unit
+class TestColorConfigGetTextColor:
+ def test_success_text(self):
+ cfg = ColorConfig()
+ assert cfg.get_text_color(TextTypeEnum.SUCCESS.value) == "green"
+
+ def test_error_text(self):
+ cfg = ColorConfig()
+ assert cfg.get_text_color(TextTypeEnum.ERROR.value) == "red"
+
+ def test_warning_text(self):
+ cfg = ColorConfig()
+ assert cfg.get_text_color(TextTypeEnum.WARNING.value) == "yellow"
+
+ def test_unknown_text_type_returns_white(self):
+ cfg = ColorConfig()
+ assert cfg.get_text_color("notype") == "white"
+
+
+# ---------------------------------------------------------------------------
+# ColorConfig — get_hierarchy_color
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.unit
+class TestColorConfigGetHierarchyColor:
+ def test_test_run_hierarchy(self):
+ cfg = ColorConfig()
+ assert cfg.get_hierarchy_color(HierarchyEnum.TEST_RUN.value) == "blue"
+
+ def test_test_suite_hierarchy(self):
+ cfg = ColorConfig()
+ assert cfg.get_hierarchy_color(HierarchyEnum.TEST_SUITE.value) == "magenta"
+
+ def test_test_case_hierarchy(self):
+ cfg = ColorConfig()
+ assert cfg.get_hierarchy_color(HierarchyEnum.TEST_CASE.value) == "cyan"
+
+ def test_test_step_hierarchy(self):
+ cfg = ColorConfig()
+ assert cfg.get_hierarchy_color(HierarchyEnum.TEST_STEP.value) == "bright_black"
+
+ def test_unknown_hierarchy_returns_white(self):
+ cfg = ColorConfig()
+ assert cfg.get_hierarchy_color("unknown_level") == "white"
+
+
+# ---------------------------------------------------------------------------
+# set_colors_enabled
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.unit
+class TestSetColorsEnabled:
+ def test_disable_colors(self):
+ set_colors_enabled(True) # ensure enabled first
+ set_colors_enabled(False)
+ from th_cli.colorize import color_config
+ assert color_config.colors_enabled is False
+
+ def test_enable_colors(self):
+ set_colors_enabled(False) # ensure disabled first
+ set_colors_enabled(True)
+ from th_cli.colorize import color_config
+ assert color_config.colors_enabled is True
+
+
+# ---------------------------------------------------------------------------
+# colorize_state
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.unit
+class TestColorizeState:
+ def test_returns_uppercase_in_brackets(self):
+ set_colors_enabled(False)
+ result = colorize_state("passed")
+ assert result == "[PASSED]"
+
+ def test_non_empty_with_colors_enabled(self):
+ set_colors_enabled(True)
+ result = colorize_state("failed")
+ assert "FAILED" in result
+ assert len(result) > 0
+
+ def test_plain_when_colors_disabled(self):
+ set_colors_enabled(False)
+ result = colorize_state("error")
+ assert result == "[ERROR]"
+
+
+# ---------------------------------------------------------------------------
+# colorize_runner_state
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.unit
+class TestColorizeRunnerState:
+ def test_plain_when_colors_disabled(self):
+ set_colors_enabled(False)
+ result = colorize_runner_state("idle")
+ assert result == "IDLE"
+
+ def test_non_empty_with_colors_enabled(self):
+ set_colors_enabled(True)
+ result = colorize_runner_state("running")
+ assert "RUNNING" in result
+
+
+# ---------------------------------------------------------------------------
+# colorize_hierarchy_prefix
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.unit
+class TestColorizeHierarchyPrefix:
+ def test_plain_when_colors_disabled(self):
+ set_colors_enabled(False)
+ result = colorize_hierarchy_prefix("Suite name", "test_suite")
+ assert result == "Suite name"
+
+ def test_non_empty_with_colors_enabled(self):
+ set_colors_enabled(True)
+ result = colorize_hierarchy_prefix("Suite name", "test_suite")
+ assert "Suite name" in result
+
+
+# ---------------------------------------------------------------------------
+# colorize_cmd_help
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.unit
+class TestColorizeCmdHelp:
+ def test_plain_when_colors_disabled(self):
+ set_colors_enabled(False)
+ result = colorize_cmd_help("run", "Execute tests")
+ assert result == "run: Execute tests"
+
+ def test_contains_cmd_and_description_with_colors_enabled(self):
+ set_colors_enabled(True)
+ result = colorize_cmd_help("run", "Execute tests")
+ assert "run" in result
+ assert "Execute tests" in result
+
+
+# ---------------------------------------------------------------------------
+# colorize_help
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.unit
+class TestColorizeHelp:
+ def test_plain_when_colors_disabled(self):
+ set_colors_enabled(False)
+ result = colorize_help("Some help text")
+ assert result == "Some help text"
+
+ def test_non_empty_with_colors_enabled(self):
+ set_colors_enabled(True)
+ result = colorize_help("Some help text")
+ assert "Some help text" in result
+
+
+# ---------------------------------------------------------------------------
+# colorize_success
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.unit
+class TestColorizeSuccess:
+ def test_plain_when_colors_disabled(self):
+ set_colors_enabled(False)
+ assert colorize_success("Done!") == "Done!"
+
+ def test_non_empty_with_colors_enabled(self):
+ set_colors_enabled(True)
+ result = colorize_success("Done!")
+ assert "Done!" in result
+
+
+# ---------------------------------------------------------------------------
+# colorize_error
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.unit
+class TestColorizeError:
+ def test_plain_when_colors_disabled(self):
+ set_colors_enabled(False)
+ assert colorize_error("Oops!") == "Oops!"
+
+ def test_non_empty_with_colors_enabled(self):
+ set_colors_enabled(True)
+ result = colorize_error("Oops!")
+ assert "Oops!" in result
+
+
+# ---------------------------------------------------------------------------
+# colorize_warning
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.unit
+class TestColorizeWarning:
+ def test_plain_when_colors_disabled(self):
+ set_colors_enabled(False)
+ assert colorize_warning("Watch out!") == "Watch out!"
+
+ def test_non_empty_with_colors_enabled(self):
+ set_colors_enabled(True)
+ result = colorize_warning("Watch out!")
+ assert "Watch out!" in result
+
+
+# ---------------------------------------------------------------------------
+# colorize_key_value
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.unit
+class TestColorizeKeyValue:
+ def test_plain_when_colors_disabled(self):
+ set_colors_enabled(False)
+ result = colorize_key_value("status", "ok")
+ assert result == "status: ok"
+
+ def test_non_empty_with_colors_enabled(self):
+ set_colors_enabled(True)
+ result = colorize_key_value("status", "ok")
+ assert "status" in result
+ assert "ok" in result
+
+ def test_value_converted_to_string(self):
+ set_colors_enabled(False)
+ result = colorize_key_value("count", 42)
+ assert "42" in result
+
+
+# ---------------------------------------------------------------------------
+# colorize_header
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.unit
+class TestColorizeHeader:
+ def test_plain_when_colors_disabled(self):
+ set_colors_enabled(False)
+ assert colorize_header("RESULTS") == "RESULTS"
+
+ def test_non_empty_with_colors_enabled(self):
+ set_colors_enabled(True)
+ result = colorize_header("RESULTS")
+ assert "RESULTS" in result
+
+
+# ---------------------------------------------------------------------------
+# colorize_dump
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.unit
+class TestColorizeDump:
+ def test_plain_when_colors_disabled(self):
+ set_colors_enabled(False)
+ assert colorize_dump('{"key": "val"}') == '{"key": "val"}'
+
+ def test_non_empty_with_colors_enabled(self):
+ set_colors_enabled(True)
+ result = colorize_dump('{"key": "val"}')
+ assert "key" in result
+
+
+# ---------------------------------------------------------------------------
+# italic
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.unit
+class TestItalic:
+ def test_plain_when_colors_disabled(self):
+ set_colors_enabled(False)
+ assert italic("hello") == "hello"
+
+ def test_non_empty_with_colors_enabled(self):
+ set_colors_enabled(True)
+ result = italic("hello")
+ assert "hello" in result
diff --git a/tests/test_config.py b/tests/test_config.py
new file mode 100644
index 0000000..9aa5518
--- /dev/null
+++ b/tests/test_config.py
@@ -0,0 +1,311 @@
+#
+# 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.
+#
+"""Unit tests for th_cli/config.py."""
+
+import json
+from pathlib import Path
+from unittest.mock import patch
+
+import pytest
+
+from th_cli.config import (
+ ATTRIBUTE_MAPPING,
+ Config,
+ LogConfig,
+ PairingMode,
+ VALID_PAIRING_MODES,
+ find_git_root,
+ get_config_search_paths,
+ get_default_config,
+ get_package_root,
+ known_cli_path,
+ load_config,
+)
+
+
+# ---------------------------------------------------------------------------
+# known_cli_path
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.unit
+class TestKnownCliPath:
+ def test_returns_path_under_home(self):
+ result = known_cli_path()
+ assert isinstance(result, Path)
+ assert result == Path.home() / "certification-tool" / "cli"
+
+ def test_ends_with_cli(self):
+ result = known_cli_path()
+ assert result.name == "cli"
+
+
+# ---------------------------------------------------------------------------
+# get_package_root
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.unit
+class TestGetPackageRoot:
+ def test_returns_path_object(self):
+ result = get_package_root()
+ assert isinstance(result, Path)
+
+ def test_is_a_directory(self):
+ result = get_package_root()
+ assert result.is_dir()
+
+ def test_contains_config_module(self):
+ result = get_package_root()
+ assert (result / "config.py").exists()
+
+
+# ---------------------------------------------------------------------------
+# find_git_root
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.unit
+class TestFindGitRoot:
+ def test_returns_none_or_path(self):
+ result = find_git_root()
+ assert result is None or isinstance(result, Path)
+
+ def test_returns_path_with_git_dir_when_found(self):
+ result = find_git_root()
+ if result is not None:
+ assert (result / ".git").exists()
+
+ def test_returns_none_when_no_git_dir(self, tmp_path):
+ """When neither package root nor known_cli_path have .git, return None."""
+ fake_pkg = tmp_path / "pkg"
+ fake_pkg.mkdir()
+ fake_cli = tmp_path / "cli"
+ fake_cli.mkdir()
+
+ with patch("th_cli.config.get_package_root", return_value=fake_pkg):
+ with patch("th_cli.config.known_cli_path", return_value=fake_cli):
+ result = find_git_root()
+ assert result is None
+
+
+# ---------------------------------------------------------------------------
+# get_config_search_paths
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.unit
+class TestGetConfigSearchPaths:
+ def test_returns_list_of_paths(self):
+ result = get_config_search_paths()
+ assert isinstance(result, list)
+ assert all(isinstance(p, Path) for p in result)
+
+ def test_includes_cwd(self):
+ import os
+ result = get_config_search_paths()
+ assert Path(os.getcwd()) in result
+
+ def test_includes_package_root(self):
+ result = get_config_search_paths()
+ assert get_package_root() in result
+
+ def test_has_at_least_two_entries(self):
+ result = get_config_search_paths()
+ assert len(result) >= 2
+
+
+# ---------------------------------------------------------------------------
+# LogConfig
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.unit
+class TestLogConfig:
+ def test_default_output_log_path(self):
+ cfg = LogConfig()
+ assert cfg.output_log_path == "./run_logs"
+
+ def test_custom_output_log_path(self):
+ cfg = LogConfig(output_log_path="/tmp/logs")
+ assert cfg.output_log_path == "/tmp/logs"
+
+ def test_default_format_contains_level(self):
+ cfg = LogConfig()
+ assert "{level" in cfg.format
+
+
+# ---------------------------------------------------------------------------
+# Config
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.unit
+class TestConfig:
+ def test_default_hostname(self):
+ cfg = Config()
+ assert cfg.hostname == "localhost"
+
+ def test_custom_hostname(self):
+ cfg = Config(hostname="192.168.1.100")
+ assert cfg.hostname == "192.168.1.100"
+
+ def test_default_log_config_is_logconfig_instance(self):
+ cfg = Config()
+ assert isinstance(cfg.log_config, LogConfig)
+
+
+# ---------------------------------------------------------------------------
+# get_default_config
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.unit
+class TestGetDefaultConfig:
+ def test_returns_dict(self):
+ result = get_default_config()
+ assert isinstance(result, dict)
+
+ def test_has_hostname_key(self):
+ result = get_default_config()
+ assert "hostname" in result
+
+ def test_has_log_config_key(self):
+ result = get_default_config()
+ assert "log_config" in result
+
+ def test_hostname_default_is_localhost(self):
+ result = get_default_config()
+ assert result["hostname"] == "localhost"
+
+
+# ---------------------------------------------------------------------------
+# load_config
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.unit
+class TestLoadConfig:
+ def test_returns_config_object_from_valid_config_json(self, tmp_path):
+ config_data = {"hostname": "myserver", "log_config": {"output_log_path": "/tmp/logs"}}
+ config_file = tmp_path / "config.json"
+ config_file.write_text(json.dumps(config_data))
+
+ with patch("th_cli.config.get_config_search_paths", return_value=[tmp_path]):
+ result = load_config()
+
+ assert isinstance(result, Config)
+ assert result.hostname == "myserver"
+
+ def test_falls_through_to_example_when_no_config_json(self, tmp_path):
+ example_data = {"hostname": "example_host"}
+ example_file = tmp_path / "config.json.example"
+ example_file.write_text(json.dumps(example_data))
+
+ with patch("th_cli.config.get_config_search_paths", return_value=[tmp_path]):
+ result = load_config()
+
+ assert isinstance(result, Config)
+ assert result.hostname == "example_host"
+
+ def test_falls_back_to_defaults_when_no_files(self, tmp_path):
+ empty_dir = tmp_path / "empty"
+ empty_dir.mkdir()
+
+ with patch("th_cli.config.get_config_search_paths", return_value=[empty_dir]):
+ result = load_config()
+
+ assert isinstance(result, Config)
+ assert result.hostname == "localhost"
+
+ def test_skips_malformed_config_json(self, tmp_path):
+ config_file = tmp_path / "config.json"
+ config_file.write_text("{this is: not valid json}")
+
+ good_dir = tmp_path / "good"
+ good_dir.mkdir()
+ good_config = good_dir / "config.json"
+ good_config.write_text(json.dumps({"hostname": "goodserver"}))
+
+ with patch("th_cli.config.get_config_search_paths", return_value=[tmp_path, good_dir]):
+ result = load_config()
+
+ assert isinstance(result, Config)
+
+ def test_handles_config_json_with_comments_in_example(self, tmp_path):
+ """config.json.example may have comment lines (# ...)."""
+ lines = ['# This is a comment\n', '{"hostname": "commented_example"}\n']
+ example_file = tmp_path / "config.json.example"
+ example_file.write_text("".join(lines))
+
+ with patch("th_cli.config.get_config_search_paths", return_value=[tmp_path]):
+ result = load_config()
+
+ assert isinstance(result, Config)
+ assert result.hostname == "commented_example"
+
+
+# ---------------------------------------------------------------------------
+# PairingMode
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.unit
+class TestPairingMode:
+ def test_ble_wifi_value(self):
+ assert PairingMode.BLE_WIFI.value == "ble-wifi"
+
+ def test_ble_thread_value(self):
+ assert PairingMode.BLE_THREAD.value == "ble-thread"
+
+ def test_nfc_thread_value(self):
+ assert PairingMode.NFC_THREAD.value == "nfc-thread"
+
+ def test_onnetwork_value(self):
+ assert PairingMode.ONNETWORK.value == "onnetwork"
+
+ def test_wifipaf_wifi_value(self):
+ assert PairingMode.WIFIPAF_WIFI.value == "wifipaf-wifi"
+
+ def test_valid_pairing_modes_contains_all(self):
+ for mode in PairingMode:
+ assert mode.value in VALID_PAIRING_MODES
+
+
+# ---------------------------------------------------------------------------
+# ATTRIBUTE_MAPPING
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.unit
+class TestAttributeMapping:
+ def test_ssid_maps_to_wifi(self):
+ assert ATTRIBUTE_MAPPING["ssid"] == ("network", "wifi")
+
+ def test_password_maps_to_wifi(self):
+ assert ATTRIBUTE_MAPPING["password"] == ("network", "wifi")
+
+ def test_channel_maps_to_thread_dataset(self):
+ assert ATTRIBUTE_MAPPING["channel"] == ("network", "thread", "dataset")
+
+ def test_networkkey_maps_to_thread_dataset(self):
+ assert ATTRIBUTE_MAPPING["networkkey"] == ("network", "thread", "dataset")
+
+ def test_rcp_serial_path_maps_to_thread(self):
+ assert ATTRIBUTE_MAPPING["rcp_serial_path"] == ("network", "thread")
+
+ def test_operational_dataset_hex_maps_to_thread(self):
+ assert ATTRIBUTE_MAPPING["operational_dataset_hex"] == ("network", "thread")
diff --git a/tests/test_exceptions.py b/tests/test_exceptions.py
new file mode 100644
index 0000000..90a4941
--- /dev/null
+++ b/tests/test_exceptions.py
@@ -0,0 +1,226 @@
+#
+# 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.
+#
+"""Unit tests for th_cli/exceptions.py."""
+
+from unittest.mock import MagicMock, patch
+
+import click
+import pytest
+
+from th_cli.exceptions import (
+ APIError,
+ CLIError,
+ ConfigurationError,
+ handle_api_error,
+ handle_file_error,
+)
+from th_cli.api_lib_autogen.exceptions import UnexpectedResponse
+
+
+# ---------------------------------------------------------------------------
+# CLIError
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.unit
+class TestCLIError:
+ def test_is_click_exception(self):
+ err = CLIError("something went wrong")
+ assert isinstance(err, click.ClickException)
+
+ def test_message_stored(self):
+ err = CLIError("bad stuff")
+ assert err.format_message() == "bad stuff"
+
+ def test_default_exit_code_is_1(self):
+ err = CLIError("bad stuff")
+ assert err.exit_code == 1
+
+ def test_custom_exit_code(self):
+ err = CLIError("bad stuff", exit_code=2)
+ assert err.exit_code == 2
+
+ def test_show_writes_to_stderr(self):
+ err = CLIError("something failed")
+ with patch("th_cli.exceptions.click.echo") as mock_echo:
+ err.show()
+ mock_echo.assert_called_once()
+ args, kwargs = mock_echo.call_args
+ assert kwargs.get("err") is True
+
+ def test_show_contains_error_message(self):
+ err = CLIError("important message")
+ captured = []
+ with patch("th_cli.exceptions.click.echo", side_effect=lambda msg, **kw: captured.append(msg)):
+ err.show()
+ assert any("important message" in str(m) for m in captured)
+
+
+# ---------------------------------------------------------------------------
+# APIError
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.unit
+class TestAPIError:
+ def test_is_cli_error(self):
+ err = APIError("api failed")
+ assert isinstance(err, CLIError)
+
+ def test_message_only(self):
+ err = APIError("api failed")
+ assert "api failed" in err.format_message()
+ assert err.status_code is None
+ assert err.content is None
+
+ def test_with_status_code(self):
+ err = APIError("not found", status_code=404)
+ msg = err.format_message()
+ assert "not found" in msg
+ assert "404" in msg
+
+ def test_with_content(self):
+ err = APIError("server error", content="Internal Server Error")
+ msg = err.format_message()
+ assert "server error" in msg
+ assert "Internal Server Error" in msg
+
+ def test_with_status_code_and_content(self):
+ err = APIError("bad request", status_code=400, content="Validation failed")
+ msg = err.format_message()
+ assert "400" in msg
+ assert "Validation failed" in msg
+
+ def test_status_code_none_not_in_message(self):
+ err = APIError("plain error", status_code=None)
+ msg = err.format_message()
+ assert "Status" not in msg
+
+ def test_content_none_not_in_message(self):
+ err = APIError("plain error", content=None)
+ msg = err.format_message()
+ assert " - None" not in msg
+
+
+# ---------------------------------------------------------------------------
+# ConfigurationError
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.unit
+class TestConfigurationError:
+ def test_is_cli_error(self):
+ err = ConfigurationError("config problem")
+ assert isinstance(err, CLIError)
+
+ def test_message_accessible(self):
+ err = ConfigurationError("missing hostname")
+ assert "missing hostname" in err.format_message()
+
+ def test_default_exit_code(self):
+ err = ConfigurationError("missing hostname")
+ assert err.exit_code == 1
+
+
+# ---------------------------------------------------------------------------
+# handle_api_error
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.unit
+class TestHandleApiError:
+ def _make_unexpected_response(self, status_code, content):
+ return UnexpectedResponse(status_code=status_code, content=content)
+
+ def test_raises_api_error(self):
+ e = self._make_unexpected_response(500, b"server error")
+ with pytest.raises(APIError):
+ handle_api_error(e, "do something")
+
+ def test_error_message_contains_operation(self):
+ e = self._make_unexpected_response(404, b"not found")
+ with pytest.raises(APIError) as exc_info:
+ handle_api_error(e, "fetch project")
+ assert "fetch project" in str(exc_info.value.format_message())
+
+ def test_bytes_content_is_decoded(self):
+ e = self._make_unexpected_response(500, b"byte content")
+ with pytest.raises(APIError) as exc_info:
+ handle_api_error(e, "op")
+ err = exc_info.value
+ assert isinstance(err.content, str)
+ assert "byte content" in err.content
+
+ def test_string_content_passed_through(self):
+ e = self._make_unexpected_response(400, "string content")
+ with pytest.raises(APIError) as exc_info:
+ handle_api_error(e, "op")
+ assert exc_info.value.content == "string content"
+
+ def test_none_content(self):
+ e = self._make_unexpected_response(503, None)
+ with pytest.raises(APIError) as exc_info:
+ handle_api_error(e, "op")
+ assert exc_info.value.content is None
+
+ def test_status_code_preserved(self):
+ e = self._make_unexpected_response(422, b"unprocessable")
+ with pytest.raises(APIError) as exc_info:
+ handle_api_error(e, "op")
+ assert exc_info.value.status_code == 422
+
+
+# ---------------------------------------------------------------------------
+# handle_file_error
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.unit
+class TestHandleFileError:
+ def _make_fnf(self, filename, strerror="No such file or directory"):
+ err = FileNotFoundError(2, strerror, filename)
+ return err
+
+ def test_raises_cli_error(self):
+ e = self._make_fnf("/some/file.txt")
+ with pytest.raises(CLIError):
+ handle_file_error(e)
+
+ def test_error_message_contains_filename(self):
+ e = self._make_fnf("/some/file.txt")
+ with pytest.raises(CLIError) as exc_info:
+ handle_file_error(e)
+ assert "/some/file.txt" in exc_info.value.format_message()
+
+ def test_error_message_contains_strerror(self):
+ e = self._make_fnf("/some/file.txt", "No such file or directory")
+ with pytest.raises(CLIError) as exc_info:
+ handle_file_error(e)
+ assert "No such file or directory" in exc_info.value.format_message()
+
+ def test_custom_file_type_in_message(self):
+ e = self._make_fnf("/config.json")
+ with pytest.raises(CLIError) as exc_info:
+ handle_file_error(e, file_type="config file")
+ # file_type.title() → "Config File"
+ assert "Config File" in exc_info.value.format_message()
+
+ def test_default_file_type_is_file(self):
+ e = self._make_fnf("/some/path")
+ with pytest.raises(CLIError) as exc_info:
+ handle_file_error(e)
+ # "file" title-cased → "File"
+ assert "File" in exc_info.value.format_message()
diff --git a/tests/test_run/camera/test_camera_http_server_additional.py b/tests/test_run/camera/test_camera_http_server_additional.py
new file mode 100644
index 0000000..f02843e
--- /dev/null
+++ b/tests/test_run/camera/test_camera_http_server_additional.py
@@ -0,0 +1,470 @@
+#
+# 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.
+#
+"""Additional coverage tests for camera_http_server.py — covers
+stream_live_video, handle_streams_api, handle_simple_proxy,
+handle_stream_proxy, serve_player, do_OPTIONS, log_message.
+"""
+
+import base64
+import json
+import queue
+from io import BytesIO
+from pathlib import Path
+from unittest.mock import MagicMock, Mock, patch
+
+import pytest
+
+from th_cli.test_run.camera.camera_http_server import VideoStreamingHandler
+
+
+# ---------------------------------------------------------------------------
+# Shared helper (same as in test_camera_http_server.py)
+# ---------------------------------------------------------------------------
+
+
+def _make_handler(path="/", method="GET", headers=None, body=b"", server_attrs=None):
+ handler = VideoStreamingHandler.__new__(VideoStreamingHandler)
+ handler.path = path
+ handler.command = method
+
+ mock_headers = MagicMock()
+ mock_headers.__contains__ = lambda self, key: key in (headers or {})
+ mock_headers.__getitem__ = lambda self, key: (headers or {})[key]
+ mock_headers.get = lambda key, default=None: (headers or {}).get(key, default)
+ handler.headers = mock_headers
+
+ handler.rfile = BytesIO(body)
+ handler.wfile = BytesIO()
+
+ mock_server = MagicMock()
+ for attr, value in (server_attrs or {}).items():
+ setattr(mock_server, attr, value)
+ handler.server = mock_server
+
+ handler._response_code = None
+ handler._headers_sent = {}
+ handler._error_code = None
+
+ handler.send_response = lambda code, msg=None: setattr(handler, "_response_code", code)
+ handler.send_header = lambda k, v: handler._headers_sent.__setitem__(k, v)
+ handler.end_headers = lambda: None
+ handler.send_error = lambda code, msg=None: setattr(handler, "_error_code", code)
+
+ return handler
+
+
+# ---------------------------------------------------------------------------
+# do_OPTIONS
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.unit
+class TestDoOptions:
+ def test_returns_200(self):
+ h = _make_handler(method="OPTIONS")
+ with patch("th_cli.test_run.camera.camera_http_server.logger"):
+ h.do_OPTIONS()
+ assert h._response_code == 200
+
+ def test_sets_cors_headers(self):
+ h = _make_handler(method="OPTIONS")
+ with patch("th_cli.test_run.camera.camera_http_server.logger"):
+ h.do_OPTIONS()
+ assert h._headers_sent.get("Access-Control-Allow-Origin") == "*"
+
+
+# ---------------------------------------------------------------------------
+# log_message (suppresses output)
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.unit
+class TestLogMessage:
+ def test_does_not_raise(self):
+ h = _make_handler()
+ h.log_message("GET %s", "/") # must not raise
+
+
+# ---------------------------------------------------------------------------
+# stream_live_video
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.unit
+class TestStreamLiveVideo:
+ def test_sends_200_with_video_mp4_content_type(self):
+ q = queue.Queue()
+ q.put(None) # immediate end-of-stream
+ h = _make_handler(server_attrs={"mp4_queue": q})
+ with patch("th_cli.test_run.camera.camera_http_server.logger"):
+ h.stream_live_video()
+ assert h._response_code == 200
+ assert h._headers_sent.get("Content-Type") == "video/mp4"
+
+ def test_streams_data_chunks_from_queue(self):
+ q = queue.Queue()
+ q.put(b"chunk1")
+ q.put(b"chunk2")
+ q.put(None) # end signal
+ h = _make_handler(server_attrs={"mp4_queue": q})
+ with patch("th_cli.test_run.camera.camera_http_server.logger"):
+ h.stream_live_video()
+ output = h.wfile.getvalue()
+ assert b"chunk1" in output
+ assert b"chunk2" in output
+
+ def test_returns_early_when_no_mp4_queue(self):
+ h = _make_handler(server_attrs={"mp4_queue": None})
+ with patch("th_cli.test_run.camera.camera_http_server.logger"):
+ h.stream_live_video()
+ # Should not crash; response was already 200
+ assert h._response_code == 200
+
+ def test_stops_on_write_error(self):
+ q = queue.Queue()
+ q.put(b"data")
+ h = _make_handler(server_attrs={"mp4_queue": q})
+ h.wfile = MagicMock()
+ h.wfile.write.side_effect = BrokenPipeError
+ with patch("th_cli.test_run.camera.camera_http_server.logger"):
+ h.stream_live_video() # must not raise
+
+
+# ---------------------------------------------------------------------------
+# serve_player
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.unit
+class TestServePlayer:
+ def test_returns_200_with_html_content_type(self):
+ h = _make_handler(server_attrs={
+ "prompt_options": {"PASS": 1},
+ "prompt_text": "Verify video",
+ "is_push_av_verification": False,
+ "push_av_server_url": None,
+ })
+ with patch("th_cli.test_run.camera.camera_http_server.logger"):
+ with patch("builtins.open", side_effect=FileNotFoundError("no template")):
+ h.serve_player()
+ assert h._response_code == 200
+ assert "text/html" in h._headers_sent.get("Content-Type", "")
+
+ def test_fallback_html_on_template_error(self):
+ h = _make_handler(server_attrs={
+ "prompt_options": {},
+ "prompt_text": "Fallback test",
+ "is_push_av_verification": False,
+ "push_av_server_url": None,
+ })
+ with patch("th_cli.test_run.camera.camera_http_server.logger"):
+ with patch("builtins.open", side_effect=Exception("template missing")):
+ h.serve_player()
+ content = h.wfile.getvalue().decode("utf-8")
+ assert "Fallback test" in content or "Error" in content
+
+ def test_sets_no_cache_headers(self):
+ h = _make_handler(server_attrs={
+ "prompt_options": {},
+ "prompt_text": "Video",
+ "is_push_av_verification": False,
+ "push_av_server_url": None,
+ })
+ with patch("th_cli.test_run.camera.camera_http_server.logger"):
+ with patch("builtins.open", side_effect=FileNotFoundError):
+ h.serve_player()
+ assert "no-store" in h._headers_sent.get("Cache-Control", "")
+
+ def test_push_av_template_selected_when_flag_set(self):
+ h = _make_handler(server_attrs={
+ "prompt_options": {"PASS": 1},
+ "prompt_text": "Push AV test",
+ "is_push_av_verification": True,
+ "push_av_server_url": "https://device:1234",
+ })
+ opened_files = []
+
+ def fake_open(path, *args, **kwargs):
+ opened_files.append(str(path))
+ raise FileNotFoundError("no template")
+
+ with patch("th_cli.test_run.camera.camera_http_server.logger"):
+ with patch("builtins.open", side_effect=fake_open):
+ h.serve_player()
+ # push_av template should have been attempted
+ assert any("push_av" in f for f in opened_files)
+
+ def test_radio_options_rendered(self):
+ h = _make_handler(server_attrs={
+ "prompt_options": {"PASS": 1, "FAIL": 2},
+ "prompt_text": "Pick one",
+ "is_push_av_verification": False,
+ "push_av_server_url": None,
+ })
+ with patch("th_cli.test_run.camera.camera_http_server.logger"):
+ with patch("builtins.open", side_effect=FileNotFoundError):
+ h.serve_player()
+ # When fallback HTML is used, wfile may not contain options,
+ # but serve_player must at minimum not raise
+ assert h._response_code == 200
+
+
+# ---------------------------------------------------------------------------
+# handle_streams_api
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.unit
+class TestHandleStreamsApi:
+ def test_returns_500_when_no_push_av_url(self):
+ h = _make_handler(server_attrs={"push_av_server_url": None})
+ with patch("th_cli.test_run.camera.camera_http_server.logger"):
+ h.handle_streams_api()
+ assert h._response_code == 500
+
+ def test_returns_streams_on_success(self):
+ h = _make_handler(server_attrs={"push_av_server_url": "http://device:1234"})
+ mock_response = MagicMock()
+ mock_response.status_code = 200
+ mock_response.json.return_value = {"streams": ["stream1"]}
+ mock_client = MagicMock()
+ mock_client.__enter__ = MagicMock(return_value=mock_client)
+ mock_client.__exit__ = MagicMock(return_value=False)
+ mock_client.get.return_value = mock_response
+
+ with patch("th_cli.test_run.camera.camera_http_server.httpx.Client", return_value=mock_client):
+ with patch("th_cli.test_run.camera.camera_http_server.logger"):
+ h.handle_streams_api()
+
+ assert h._response_code == 200
+ output = json.loads(h.wfile.getvalue())
+ assert output == {"streams": ["stream1"]}
+
+ def test_returns_500_on_non_200_response(self):
+ h = _make_handler(server_attrs={"push_av_server_url": "http://device:1234"})
+ mock_response = MagicMock()
+ mock_response.status_code = 404
+ mock_client = MagicMock()
+ mock_client.__enter__ = MagicMock(return_value=mock_client)
+ mock_client.__exit__ = MagicMock(return_value=False)
+ mock_client.get.return_value = mock_response
+
+ with patch("th_cli.test_run.camera.camera_http_server.httpx.Client", return_value=mock_client):
+ with patch("th_cli.test_run.camera.camera_http_server.logger"):
+ h.handle_streams_api()
+
+ assert h._response_code == 500
+
+ def test_returns_500_on_connection_error(self):
+ h = _make_handler(server_attrs={"push_av_server_url": "http://device:1234"})
+ mock_client = MagicMock()
+ mock_client.__enter__ = MagicMock(return_value=mock_client)
+ mock_client.__exit__ = MagicMock(return_value=False)
+ mock_client.get.side_effect = Exception("connection failed")
+
+ with patch("th_cli.test_run.camera.camera_http_server.httpx.Client", return_value=mock_client):
+ with patch("th_cli.test_run.camera.camera_http_server.logger"):
+ h.handle_streams_api()
+
+ assert h._response_code == 500
+
+
+# ---------------------------------------------------------------------------
+# handle_simple_proxy
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.unit
+class TestHandleSimpleProxy:
+ def _encoded_url(self, url: str) -> str:
+ return base64.urlsafe_b64encode(url.encode()).decode("ascii")
+
+ def test_returns_200_on_success(self):
+ encoded = self._encoded_url("http://device/stream")
+ h = _make_handler(path=f"/proxy/{encoded}")
+
+ mock_response = MagicMock()
+ mock_response.status_code = 200
+ mock_response.headers = {"Content-Type": "video/mp4"}
+ mock_response.content = b"mp4data"
+ mock_client = MagicMock()
+ mock_client.__enter__ = MagicMock(return_value=mock_client)
+ mock_client.__exit__ = MagicMock(return_value=False)
+ mock_client.get.return_value = mock_response
+
+ with patch("th_cli.test_run.camera.camera_http_server.httpx.Client", return_value=mock_client):
+ with patch("th_cli.test_run.camera.camera_http_server.logger"):
+ h.handle_simple_proxy()
+
+ assert h._response_code == 200
+ assert h.wfile.getvalue() == b"mp4data"
+
+ def test_returns_400_on_invalid_base64(self):
+ h = _make_handler(path="/proxy/!!!invalid!!!")
+ with patch("th_cli.test_run.camera.camera_http_server.logger"):
+ h.handle_simple_proxy()
+ assert h._error_code == 400
+
+ def test_returns_upstream_status_on_non_200(self):
+ encoded = self._encoded_url("http://device/missing")
+ h = _make_handler(path=f"/proxy/{encoded}")
+
+ mock_response = MagicMock()
+ mock_response.status_code = 404
+ mock_client = MagicMock()
+ mock_client.__enter__ = MagicMock(return_value=mock_client)
+ mock_client.__exit__ = MagicMock(return_value=False)
+ mock_client.get.return_value = mock_response
+
+ with patch("th_cli.test_run.camera.camera_http_server.httpx.Client", return_value=mock_client):
+ with patch("th_cli.test_run.camera.camera_http_server.logger"):
+ h.handle_simple_proxy()
+
+ assert h._error_code == 404
+
+ def test_returns_500_on_fetch_error(self):
+ encoded = self._encoded_url("http://device/stream")
+ h = _make_handler(path=f"/proxy/{encoded}")
+
+ mock_client = MagicMock()
+ mock_client.__enter__ = MagicMock(return_value=mock_client)
+ mock_client.__exit__ = MagicMock(return_value=False)
+ mock_client.get.side_effect = Exception("network error")
+
+ with patch("th_cli.test_run.camera.camera_http_server.httpx.Client", return_value=mock_client):
+ with patch("th_cli.test_run.camera.camera_http_server.logger"):
+ h.handle_simple_proxy()
+
+ assert h._error_code == 500
+
+ def test_extra_path_appended_to_url(self):
+ encoded = self._encoded_url("http://device")
+ h = _make_handler(path=f"/proxy/{encoded}/segment/0.ts")
+
+ fetched_urls = []
+ mock_response = MagicMock()
+ mock_response.status_code = 200
+ mock_response.headers = {"Content-Type": "video/mp2t"}
+ mock_response.content = b"ts_data"
+ mock_client = MagicMock()
+ mock_client.__enter__ = MagicMock(return_value=mock_client)
+ mock_client.__exit__ = MagicMock(return_value=False)
+
+ def fake_get(url, **kwargs):
+ fetched_urls.append(url)
+ return mock_response
+
+ mock_client.get = fake_get
+
+ with patch("th_cli.test_run.camera.camera_http_server.httpx.Client", return_value=mock_client):
+ with patch("th_cli.test_run.camera.camera_http_server.logger"):
+ h.handle_simple_proxy()
+
+ assert fetched_urls and "/segment/0.ts" in fetched_urls[0]
+
+
+# ---------------------------------------------------------------------------
+# handle_stream_proxy
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.unit
+class TestHandleStreamProxy:
+ def test_returns_400_when_no_url_param(self):
+ h = _make_handler(path="/api/stream_proxy")
+ with patch("th_cli.test_run.camera.camera_http_server.logger"):
+ h.handle_stream_proxy()
+ assert h._error_code == 400
+
+ def test_returns_regular_content_on_success(self):
+ h = _make_handler(path="/api/stream_proxy?url=http://device/video.mp4")
+
+ mock_response = MagicMock()
+ mock_response.status_code = 200
+ mock_response.headers = {"Content-Type": "video/mp4"}
+ mock_response.content = b"video_data"
+ mock_response.text = ""
+ mock_client = MagicMock()
+ mock_client.__enter__ = MagicMock(return_value=mock_client)
+ mock_client.__exit__ = MagicMock(return_value=False)
+ mock_client.get.return_value = mock_response
+
+ with patch("th_cli.test_run.camera.camera_http_server.httpx.Client", return_value=mock_client):
+ with patch("th_cli.test_run.camera.camera_http_server.logger"):
+ h.handle_stream_proxy()
+
+ assert h._response_code == 200
+ assert h.wfile.getvalue() == b"video_data"
+
+ def test_returns_upstream_error_status(self):
+ h = _make_handler(path="/api/stream_proxy?url=http://device/missing.mp4")
+
+ mock_response = MagicMock()
+ mock_response.status_code = 404
+ mock_client = MagicMock()
+ mock_client.__enter__ = MagicMock(return_value=mock_client)
+ mock_client.__exit__ = MagicMock(return_value=False)
+ mock_client.get.return_value = mock_response
+
+ with patch("th_cli.test_run.camera.camera_http_server.httpx.Client", return_value=mock_client):
+ with patch("th_cli.test_run.camera.camera_http_server.logger"):
+ h.handle_stream_proxy()
+
+ assert h._error_code == 404
+
+ def test_rewrites_mpd_manifest(self):
+ mpd_content = """
+
+
+"""
+ h = _make_handler(
+ path="/api/stream_proxy?url=http://device/stream.mpd",
+ server_attrs={"local_ip": "10.0.0.1", "server_port": 8999},
+ )
+
+ mock_response = MagicMock()
+ mock_response.status_code = 200
+ mock_response.headers = {"Content-Type": "application/dash+xml"}
+ mock_response.text = mpd_content
+ mock_response.content = mpd_content.encode()
+ mock_client = MagicMock()
+ mock_client.__enter__ = MagicMock(return_value=mock_client)
+ mock_client.__exit__ = MagicMock(return_value=False)
+ mock_client.get.return_value = mock_response
+
+ with patch("th_cli.test_run.camera.camera_http_server.httpx.Client", return_value=mock_client):
+ with patch("th_cli.test_run.camera.camera_http_server.logger"):
+ h.handle_stream_proxy()
+
+ assert h._response_code == 200
+ # Rewritten manifest should be served
+ content = h.wfile.getvalue().decode("utf-8")
+ assert "BaseURL" in content or "proxy" in content
+
+ def test_returns_500_on_exception(self):
+ h = _make_handler(path="/api/stream_proxy?url=http://device/video.mp4")
+ mock_client = MagicMock()
+ mock_client.__enter__ = MagicMock(return_value=mock_client)
+ mock_client.__exit__ = MagicMock(return_value=False)
+ mock_client.get.side_effect = Exception("network error")
+ h.wfile = MagicMock()
+ h.wfile.closed = False
+
+ with patch("th_cli.test_run.camera.camera_http_server.httpx.Client", return_value=mock_client):
+ with patch("th_cli.test_run.camera.camera_http_server.logger"):
+ h.handle_stream_proxy()
+
+ assert h._error_code == 500
diff --git a/tests/test_run/camera/test_camera_stream_handler_additional.py b/tests/test_run/camera/test_camera_stream_handler_additional.py
new file mode 100644
index 0000000..99ab266
--- /dev/null
+++ b/tests/test_run/camera/test_camera_stream_handler_additional.py
@@ -0,0 +1,280 @@
+#
+# 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.
+#
+"""Additional coverage tests for camera_stream_handler.py — covers
+start_video_capture_and_stream, wait_for_stream_ready, _initialize_video_capture,
+wait_for_user_response, stop_video_capture_and_stream.
+"""
+
+import asyncio
+import queue
+from pathlib import Path
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+
+from th_cli.test_run.camera.camera_stream_handler import CameraStreamHandler
+
+
+# ---------------------------------------------------------------------------
+# Helpers
+# ---------------------------------------------------------------------------
+
+
+def _make_handler(output_dir=None) -> CameraStreamHandler:
+ with patch("th_cli.test_run.camera.camera_stream_handler.VideoWebSocketManager"):
+ with patch("th_cli.test_run.camera.camera_stream_handler.CameraHTTPServer"):
+ if output_dir:
+ return CameraStreamHandler(output_dir=str(output_dir))
+ with patch.object(Path, "mkdir"):
+ return CameraStreamHandler()
+
+
+# ---------------------------------------------------------------------------
+# start_video_capture_and_stream
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.unit
+class TestStartVideoCaptureAndStream:
+ @pytest.mark.asyncio
+ async def test_returns_a_path(self, tmp_path):
+ h = _make_handler(output_dir=tmp_path)
+ with patch("th_cli.test_run.camera.camera_stream_handler.asyncio.create_task"):
+ with patch("th_cli.test_run.camera.camera_stream_handler.logger"):
+ result = await h.start_video_capture_and_stream("prompt_1")
+ assert isinstance(result, Path)
+
+ @pytest.mark.asyncio
+ async def test_path_contains_prompt_id(self, tmp_path):
+ h = _make_handler(output_dir=tmp_path)
+ with patch("th_cli.test_run.camera.camera_stream_handler.asyncio.create_task"):
+ with patch("th_cli.test_run.camera.camera_stream_handler.logger"):
+ result = await h.start_video_capture_and_stream("myprompt42")
+ assert "myprompt42" in result.name
+
+ @pytest.mark.asyncio
+ async def test_clears_stream_ready_event(self, tmp_path):
+ h = _make_handler(output_dir=tmp_path)
+ h.stream_ready_event.set() # pre-set it
+ with patch("th_cli.test_run.camera.camera_stream_handler.asyncio.create_task"):
+ with patch("th_cli.test_run.camera.camera_stream_handler.logger"):
+ await h.start_video_capture_and_stream("p")
+ assert not h.stream_ready_event.is_set()
+
+ @pytest.mark.asyncio
+ async def test_resets_initialization_error(self, tmp_path):
+ h = _make_handler(output_dir=tmp_path)
+ h.initialization_error = "old error"
+ with patch("th_cli.test_run.camera.camera_stream_handler.asyncio.create_task"):
+ with patch("th_cli.test_run.camera.camera_stream_handler.logger"):
+ await h.start_video_capture_and_stream("p")
+ assert h.initialization_error is None
+
+ @pytest.mark.asyncio
+ async def test_calls_http_server_start(self, tmp_path):
+ h = _make_handler(output_dir=tmp_path)
+ h.prompt_options = {"PASS": 1}
+ h.prompt_text = "Check video"
+ with patch("th_cli.test_run.camera.camera_stream_handler.asyncio.create_task"):
+ with patch("th_cli.test_run.camera.camera_stream_handler.logger"):
+ await h.start_video_capture_and_stream("p")
+ h.http_server.start.assert_called_once()
+
+
+# ---------------------------------------------------------------------------
+# wait_for_stream_ready
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.unit
+class TestWaitForStreamReady:
+ @pytest.mark.asyncio
+ async def test_returns_true_when_event_set_immediately(self):
+ h = _make_handler()
+ h.stream_ready_event.set()
+ result = await h.wait_for_stream_ready(timeout=1.0)
+ assert result is True
+
+ @pytest.mark.asyncio
+ async def test_returns_false_on_timeout(self):
+ h = _make_handler()
+ with patch("th_cli.test_run.camera.camera_stream_handler.logger"):
+ result = await h.wait_for_stream_ready(timeout=0.05)
+ assert result is False
+
+ @pytest.mark.asyncio
+ async def test_returns_true_when_event_set_during_wait(self):
+ h = _make_handler()
+
+ async def _set_later():
+ await asyncio.sleep(0.03)
+ h.stream_ready_event.set()
+
+ asyncio.create_task(_set_later())
+ result = await h.wait_for_stream_ready(timeout=2.0)
+ assert result is True
+
+
+# ---------------------------------------------------------------------------
+# _initialize_video_capture
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.unit
+class TestInitializeVideoCapture:
+ @pytest.mark.asyncio
+ async def test_sets_error_when_ffmpeg_not_installed(self, tmp_path):
+ h = _make_handler(output_dir=tmp_path)
+ h.current_stream_file = tmp_path / "vid.bin"
+
+ with patch(
+ "th_cli.test_run.camera.camera_stream_handler.FFmpegStreamConverter.check_ffmpeg_installed",
+ return_value=(False, "ffmpeg not found"),
+ ):
+ with patch("th_cli.test_run.camera.camera_stream_handler.logger"):
+ await h._initialize_video_capture()
+
+ assert h.initialization_error == "ffmpeg not found"
+ assert not h.stream_ready_event.is_set()
+
+ @pytest.mark.asyncio
+ async def test_sets_event_when_connect_succeeds(self, tmp_path):
+ h = _make_handler(output_dir=tmp_path)
+ h.current_stream_file = tmp_path / "vid.bin"
+
+ with patch(
+ "th_cli.test_run.camera.camera_stream_handler.FFmpegStreamConverter.check_ffmpeg_installed",
+ return_value=(True, ""),
+ ):
+ h.websocket_manager.wait_and_connect_with_retry = AsyncMock(return_value=True)
+ h.websocket_manager.start_capture_and_stream = AsyncMock()
+ with patch("th_cli.test_run.camera.camera_stream_handler.logger"):
+ await h._initialize_video_capture()
+
+ assert h.stream_ready_event.is_set()
+
+ @pytest.mark.asyncio
+ async def test_sets_error_when_connect_fails(self, tmp_path):
+ h = _make_handler(output_dir=tmp_path)
+ h.current_stream_file = tmp_path / "vid.bin"
+
+ with patch(
+ "th_cli.test_run.camera.camera_stream_handler.FFmpegStreamConverter.check_ffmpeg_installed",
+ return_value=(True, ""),
+ ):
+ h.websocket_manager.wait_and_connect_with_retry = AsyncMock(return_value=False)
+ with patch("th_cli.test_run.camera.camera_stream_handler.logger"):
+ await h._initialize_video_capture()
+
+ assert h.initialization_error is not None
+ assert not h.stream_ready_event.is_set()
+
+ @pytest.mark.asyncio
+ async def test_handles_unexpected_exception(self, tmp_path):
+ h = _make_handler(output_dir=tmp_path)
+ h.current_stream_file = tmp_path / "vid.bin"
+
+ with patch(
+ "th_cli.test_run.camera.camera_stream_handler.FFmpegStreamConverter.check_ffmpeg_installed",
+ side_effect=RuntimeError("unexpected"),
+ ):
+ with patch("th_cli.test_run.camera.camera_stream_handler.logger"):
+ await h._initialize_video_capture() # must not raise
+
+ assert h.initialization_error is not None
+
+
+# ---------------------------------------------------------------------------
+# wait_for_user_response
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.unit
+class TestWaitForUserResponseStream:
+ @pytest.mark.asyncio
+ async def test_returns_queued_response_immediately(self):
+ h = _make_handler()
+ h.response_queue.put_nowait(1)
+ result = await h.wait_for_user_response(timeout=1.0)
+ assert result == 1
+
+ @pytest.mark.asyncio
+ async def test_returns_none_on_timeout(self):
+ h = _make_handler()
+ with patch("th_cli.test_run.camera.camera_stream_handler.logger"):
+ result = await h.wait_for_user_response(timeout=0.1)
+ assert result is None
+
+ @pytest.mark.asyncio
+ async def test_returns_response_enqueued_during_wait(self):
+ h = _make_handler()
+
+ async def _enqueue_later():
+ await asyncio.sleep(0.05)
+ h.response_queue.put_nowait(42)
+
+ asyncio.create_task(_enqueue_later())
+ with patch("th_cli.test_run.camera.camera_stream_handler.logger"):
+ result = await h.wait_for_user_response(timeout=2.0)
+ assert result == 42
+
+
+# ---------------------------------------------------------------------------
+# stop_video_capture_and_stream
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.unit
+class TestStopVideoCaptureAndStream:
+ @pytest.mark.asyncio
+ async def test_stops_websocket_and_http_server(self, tmp_path):
+ h = _make_handler(output_dir=tmp_path)
+ h.websocket_manager.stop = AsyncMock()
+
+ with patch("th_cli.test_run.camera.camera_stream_handler.logger"):
+ await h.stop_video_capture_and_stream()
+
+ h.websocket_manager.stop.assert_called_once()
+ h.http_server.stop.assert_called_once()
+
+ @pytest.mark.asyncio
+ async def test_returns_none_when_no_stream_file(self):
+ h = _make_handler()
+ h.current_stream_file = None
+ h.websocket_manager.stop = AsyncMock()
+ with patch("th_cli.test_run.camera.camera_stream_handler.logger"):
+ result = await h.stop_video_capture_and_stream()
+ assert result is None
+
+ @pytest.mark.asyncio
+ async def test_returns_path_when_stream_file_exists(self, tmp_path):
+ h = _make_handler(output_dir=tmp_path)
+ stream_file = tmp_path / "vid.bin"
+ stream_file.write_bytes(b"data")
+ h.current_stream_file = stream_file
+ h.websocket_manager.stop = AsyncMock()
+ with patch("th_cli.test_run.camera.camera_stream_handler.logger"):
+ result = await h.stop_video_capture_and_stream()
+ assert result == stream_file
+
+ @pytest.mark.asyncio
+ async def test_puts_none_sentinel_in_mp4_queue(self):
+ h = _make_handler()
+ h.websocket_manager.stop = AsyncMock()
+ with patch("th_cli.test_run.camera.camera_stream_handler.logger"):
+ await h.stop_video_capture_and_stream()
+ # Queue should have None sentinel
+ assert h.mp4_queue.get_nowait() is None
diff --git a/tests/test_run/camera/test_websocket_manager.py b/tests/test_run/camera/test_websocket_manager.py
new file mode 100644
index 0000000..fbed1eb
--- /dev/null
+++ b/tests/test_run/camera/test_websocket_manager.py
@@ -0,0 +1,344 @@
+#
+# 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.
+#
+"""Unit tests for th_cli/test_run/camera/websocket_manager.py."""
+
+import asyncio
+import queue
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+
+from th_cli.test_run.camera.websocket_manager import VideoWebSocketManager
+
+
+# ---------------------------------------------------------------------------
+# __init__
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.unit
+class TestVideoWebSocketManagerInit:
+ def test_video_websocket_initially_none(self):
+ mgr = VideoWebSocketManager()
+ assert mgr.video_websocket is None
+
+ def test_streaming_active_initially_false(self):
+ mgr = VideoWebSocketManager()
+ assert mgr.streaming_active is False
+
+ def test_ffmpeg_converter_initially_none(self):
+ mgr = VideoWebSocketManager()
+ assert mgr.ffmpeg_converter is None
+
+
+# ---------------------------------------------------------------------------
+# connect()
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.unit
+class TestVideoWebSocketManagerConnect:
+ @pytest.mark.asyncio
+ async def test_returns_true_on_success(self):
+ mgr = VideoWebSocketManager()
+ mock_ws = AsyncMock()
+ # ping() returns a future; wait_for on it should resolve without blocking
+ pong_future = asyncio.get_event_loop().create_future()
+ pong_future.set_result(None)
+ mock_ws.ping = AsyncMock(return_value=pong_future)
+
+ with patch("th_cli.test_run.camera.websocket_manager.logger"):
+ with patch(
+ "th_cli.test_run.camera.websocket_manager.websocket_connect",
+ new_callable=AsyncMock,
+ return_value=mock_ws,
+ ):
+ result = await mgr.connect()
+
+ assert result is True
+ assert mgr.video_websocket is mock_ws
+
+ @pytest.mark.asyncio
+ async def test_returns_false_when_connect_raises(self):
+ mgr = VideoWebSocketManager()
+ with patch("th_cli.test_run.camera.websocket_manager.logger"):
+ with patch(
+ "th_cli.test_run.camera.websocket_manager.websocket_connect",
+ side_effect=ConnectionRefusedError("refused"),
+ ):
+ result = await mgr.connect()
+
+ assert result is False
+ assert mgr.video_websocket is None
+
+ @pytest.mark.asyncio
+ async def test_returns_true_even_when_ping_fails(self):
+ """Ping failure is non-fatal — connect should still return True."""
+ mgr = VideoWebSocketManager()
+ mock_ws = AsyncMock()
+ mock_ws.ping = AsyncMock(side_effect=Exception("ping timeout"))
+
+ with patch("th_cli.test_run.camera.websocket_manager.logger"):
+ with patch(
+ "th_cli.test_run.camera.websocket_manager.websocket_connect",
+ new_callable=AsyncMock,
+ return_value=mock_ws,
+ ):
+ result = await mgr.connect()
+
+ assert result is True
+
+
+# ---------------------------------------------------------------------------
+# reset()
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.unit
+class TestVideoWebSocketManagerReset:
+ def test_sets_streaming_active_false(self):
+ mgr = VideoWebSocketManager()
+ mgr.streaming_active = True
+ with patch("th_cli.test_run.camera.websocket_manager.logger"):
+ mgr.reset()
+ assert mgr.streaming_active is False
+
+ def test_stops_ffmpeg_converter_if_present(self):
+ mgr = VideoWebSocketManager()
+ mock_converter = MagicMock()
+ mgr.ffmpeg_converter = mock_converter
+
+ with patch("th_cli.test_run.camera.websocket_manager.logger"):
+ mgr.reset()
+
+ mock_converter.stop.assert_called_once()
+ assert mgr.ffmpeg_converter is None
+
+ def test_ffmpeg_converter_none_when_not_present(self):
+ mgr = VideoWebSocketManager()
+ with patch("th_cli.test_run.camera.websocket_manager.logger"):
+ mgr.reset()
+ assert mgr.ffmpeg_converter is None
+
+ def test_handles_converter_stop_exception(self):
+ mgr = VideoWebSocketManager()
+ mock_converter = MagicMock()
+ mock_converter.stop.side_effect = RuntimeError("already stopped")
+ mgr.ffmpeg_converter = mock_converter
+
+ with patch("th_cli.test_run.camera.websocket_manager.logger"):
+ mgr.reset() # must not raise
+
+ assert mgr.ffmpeg_converter is None
+
+
+# ---------------------------------------------------------------------------
+# stop()
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.unit
+class TestVideoWebSocketManagerStop:
+ @pytest.mark.asyncio
+ async def test_sets_streaming_active_false(self):
+ mgr = VideoWebSocketManager()
+ mgr.streaming_active = True
+ with patch("th_cli.test_run.camera.websocket_manager.logger"):
+ await mgr.stop()
+ assert mgr.streaming_active is False
+
+ @pytest.mark.asyncio
+ async def test_stops_ffmpeg_converter_if_present(self):
+ mgr = VideoWebSocketManager()
+ mock_converter = MagicMock()
+ mgr.ffmpeg_converter = mock_converter
+
+ with patch("th_cli.test_run.camera.websocket_manager.logger"):
+ await mgr.stop()
+
+ mock_converter.stop.assert_called_once()
+ assert mgr.ffmpeg_converter is None
+
+ @pytest.mark.asyncio
+ async def test_closes_websocket_if_present(self):
+ mgr = VideoWebSocketManager()
+ mock_ws = AsyncMock()
+ mgr.video_websocket = mock_ws
+
+ with patch("th_cli.test_run.camera.websocket_manager.logger"):
+ await mgr.stop()
+
+ mock_ws.close.assert_called_once()
+ assert mgr.video_websocket is None
+
+ @pytest.mark.asyncio
+ async def test_noop_when_all_fields_none(self):
+ mgr = VideoWebSocketManager()
+ with patch("th_cli.test_run.camera.websocket_manager.logger"):
+ await mgr.stop() # must not raise
+ assert mgr.streaming_active is False
+
+ @pytest.mark.asyncio
+ async def test_handles_close_exception_gracefully(self):
+ mgr = VideoWebSocketManager()
+ mock_ws = AsyncMock()
+ mock_ws.close.side_effect = Exception("already closed")
+ mgr.video_websocket = mock_ws
+
+ with patch("th_cli.test_run.camera.websocket_manager.logger"):
+ await mgr.stop() # must not raise
+
+ assert mgr.video_websocket is None
+
+
+# ---------------------------------------------------------------------------
+# start_capture_and_stream() — no-op when not connected
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.unit
+class TestStartCaptureAndStream:
+ @pytest.mark.asyncio
+ async def test_returns_immediately_when_not_connected(self):
+ mgr = VideoWebSocketManager()
+ # video_websocket is None — should log error and return immediately
+ with patch("th_cli.test_run.camera.websocket_manager.logger"):
+ await mgr.start_capture_and_stream(stream_file=None, mp4_queue=None)
+ assert mgr.streaming_active is False
+
+
+# ---------------------------------------------------------------------------
+# wait_and_connect_with_retry()
+#
+# NOTE: In the source, the retry loop only increments `attempt` inside the
+# `except` block — so a `connect()` that consistently returns False (without
+# raising) causes an infinite loop. We therefore test:
+# 1. connect() succeeds → returns True immediately
+# 2. connect() raises each time → retries up to max_attempts, returns False
+# 3. Pre-existing websocket is closed before first attempt
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.unit
+class TestWaitAndConnectWithRetry:
+ @pytest.mark.asyncio
+ async def test_returns_true_on_first_success(self):
+ mgr = VideoWebSocketManager()
+
+ with patch.object(mgr, "connect", new_callable=AsyncMock, return_value=True):
+ with patch("th_cli.test_run.camera.websocket_manager.logger"):
+ result = await mgr.wait_and_connect_with_retry(max_attempts=3)
+
+ assert result is True
+
+ @pytest.mark.asyncio
+ async def test_returns_false_after_all_attempts_raise(self):
+ """Retry loop increments attempt only on exception; use side_effect to raise."""
+ mgr = VideoWebSocketManager()
+
+ with patch.object(
+ mgr, "connect", new_callable=AsyncMock, side_effect=ConnectionRefusedError("refused")
+ ):
+ with patch(
+ "th_cli.test_run.camera.websocket_manager.asyncio.sleep",
+ new_callable=AsyncMock,
+ ):
+ with patch("th_cli.test_run.camera.websocket_manager.logger"):
+ result = await mgr.wait_and_connect_with_retry(max_attempts=2)
+
+ assert result is False
+
+ @pytest.mark.asyncio
+ async def test_closes_existing_websocket_before_connecting(self):
+ mgr = VideoWebSocketManager()
+ mock_ws = AsyncMock()
+ mgr.video_websocket = mock_ws
+
+ with patch.object(mgr, "connect", new_callable=AsyncMock, return_value=True):
+ with patch("th_cli.test_run.camera.websocket_manager.logger"):
+ await mgr.wait_and_connect_with_retry(max_attempts=1)
+
+ mock_ws.close.assert_called_once()
+
+ @pytest.mark.asyncio
+ async def test_handles_existing_websocket_close_error(self):
+ mgr = VideoWebSocketManager()
+ mock_ws = AsyncMock()
+ mock_ws.close.side_effect = Exception("already closed")
+ mgr.video_websocket = mock_ws
+
+ with patch.object(mgr, "connect", new_callable=AsyncMock, return_value=True):
+ with patch("th_cli.test_run.camera.websocket_manager.logger"):
+ result = await mgr.wait_and_connect_with_retry(max_attempts=1)
+
+ assert result is True
+
+
+# ---------------------------------------------------------------------------
+# _transfer_converted_data()
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.unit
+class TestTransferConvertedData:
+ def test_puts_converted_data_into_queue(self):
+ mgr = VideoWebSocketManager()
+ mgr.streaming_active = True
+
+ mock_converter = MagicMock()
+ call_count = [0]
+
+ def get_converted_data(timeout=1.0):
+ call_count[0] += 1
+ if call_count[0] == 1:
+ return b"mp4chunk"
+ mgr.streaming_active = False # stop after first chunk
+ return None
+
+ mock_converter.get_converted_data.side_effect = get_converted_data
+ mgr.ffmpeg_converter = mock_converter
+
+ mp4_queue = queue.Queue()
+
+ with patch("th_cli.test_run.camera.websocket_manager.logger"):
+ mgr._transfer_converted_data(mp4_queue)
+
+ assert not mp4_queue.empty()
+ assert mp4_queue.get_nowait() == b"mp4chunk"
+
+ def test_handles_full_queue_without_exception(self):
+ mgr = VideoWebSocketManager()
+ mgr.streaming_active = True
+
+ mock_converter = MagicMock()
+ call_count = [0]
+
+ def get_converted_data(timeout=1.0):
+ call_count[0] += 1
+ if call_count[0] == 1:
+ return b"data"
+ mgr.streaming_active = False
+ return None
+
+ mock_converter.get_converted_data.side_effect = get_converted_data
+ mgr.ffmpeg_converter = mock_converter
+
+ # Pre-fill queue to capacity so the put_nowait raises queue.Full
+ mp4_queue_full = queue.Queue(maxsize=1)
+ mp4_queue_full.put_nowait(b"already_full")
+
+ with patch("th_cli.test_run.camera.websocket_manager.logger"):
+ mgr._transfer_converted_data(mp4_queue_full) # must not raise
diff --git a/tests/test_run/test_log_stream_handler.py b/tests/test_run/test_log_stream_handler.py
new file mode 100644
index 0000000..1f41df8
--- /dev/null
+++ b/tests/test_run/test_log_stream_handler.py
@@ -0,0 +1,292 @@
+#
+# 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.
+#
+"""Unit tests for th_cli/test_run/log_stream_handler.py."""
+
+import queue
+import socket
+from unittest.mock import MagicMock, patch
+
+import pytest
+
+from th_cli.test_run.log_stream_handler import LogStreamHandler
+
+
+# ---------------------------------------------------------------------------
+# LogStreamHandler.__init__
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.unit
+class TestLogStreamHandlerInit:
+ def test_port_stored(self):
+ with patch("th_cli.test_run.log_stream_handler.LogsHTTPServer"):
+ h = LogStreamHandler(port=9000)
+ assert h.port == 9000
+
+ def test_default_port(self):
+ with patch("th_cli.test_run.log_stream_handler.LogsHTTPServer"):
+ h = LogStreamHandler()
+ assert h.port == 8998
+
+ def test_is_running_initially_false(self):
+ with patch("th_cli.test_run.log_stream_handler.LogsHTTPServer"):
+ h = LogStreamHandler()
+ assert h.is_running is False
+
+ def test_log_queue_is_queue(self):
+ with patch("th_cli.test_run.log_stream_handler.LogsHTTPServer"):
+ h = LogStreamHandler()
+ assert isinstance(h.log_queue, queue.Queue)
+
+ def test_log_file_path_initially_none(self):
+ with patch("th_cli.test_run.log_stream_handler.LogsHTTPServer"):
+ h = LogStreamHandler()
+ assert h.log_file_path is None
+
+
+# ---------------------------------------------------------------------------
+# Helpers
+# ---------------------------------------------------------------------------
+
+
+def _make_handler(port=8998):
+ """Create a LogStreamHandler with a mocked LogsHTTPServer."""
+ with patch("th_cli.test_run.log_stream_handler.LogsHTTPServer") as mock_cls:
+ mock_srv = MagicMock()
+ mock_cls.return_value = mock_srv
+ h = LogStreamHandler(port=port)
+ h.http_server = mock_srv
+ return h, mock_srv
+
+
+# ---------------------------------------------------------------------------
+# start()
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.unit
+class TestLogStreamHandlerStart:
+ def test_sets_is_running_true(self):
+ h, mock_srv = _make_handler()
+ with patch.object(h, "_get_local_ip", return_value="10.0.0.1"):
+ with patch("th_cli.test_run.log_stream_handler.logger"):
+ h.start(test_run_title="Test Run")
+ assert h.is_running is True
+
+ def test_returns_url_with_local_ip_and_port(self):
+ h, mock_srv = _make_handler(port=9001)
+ with patch.object(h, "_get_local_ip", return_value="192.168.1.5"):
+ with patch("th_cli.test_run.log_stream_handler.logger"):
+ url = h.start(test_run_title="My Run")
+ assert "192.168.1.5" in url
+ assert "9001" in url
+
+ def test_calls_http_server_start(self):
+ h, mock_srv = _make_handler()
+ with patch.object(h, "_get_local_ip", return_value="10.0.0.1"):
+ with patch("th_cli.test_run.log_stream_handler.logger"):
+ h.start(test_run_title="run", log_file_path="/tmp/test.log")
+ mock_srv.start.assert_called_once()
+
+ def test_stores_log_file_path(self):
+ h, mock_srv = _make_handler()
+ with patch.object(h, "_get_local_ip", return_value="10.0.0.1"):
+ with patch("th_cli.test_run.log_stream_handler.logger"):
+ h.start(test_run_title="run", log_file_path="/var/log/test.log")
+ assert h.log_file_path == "/var/log/test.log"
+
+ def test_already_running_returns_url_without_restart(self):
+ h, mock_srv = _make_handler()
+ h.is_running = True
+
+ with patch.object(h, "_get_local_ip", return_value="10.0.0.2"):
+ with patch("th_cli.test_run.log_stream_handler.logger"):
+ url = h.start(test_run_title="run")
+
+ mock_srv.start.assert_not_called()
+ assert url # URL returned
+
+ def test_propagates_exception_from_server_start(self):
+ h, mock_srv = _make_handler()
+ mock_srv.start.side_effect = OSError("port in use")
+
+ with patch.object(h, "_get_local_ip", return_value="10.0.0.1"):
+ with patch("th_cli.test_run.log_stream_handler.logger"):
+ with pytest.raises(OSError):
+ h.start(test_run_title="run")
+
+ assert h.is_running is False
+
+
+# ---------------------------------------------------------------------------
+# stop()
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.unit
+class TestLogStreamHandlerStop:
+ def test_noop_when_not_running(self):
+ h, mock_srv = _make_handler()
+ h.is_running = False
+ with patch("th_cli.test_run.log_stream_handler.logger"):
+ h.stop()
+ mock_srv.stop.assert_not_called()
+
+ def test_calls_http_server_stop(self):
+ h, mock_srv = _make_handler()
+ h.is_running = True
+
+ with patch("th_cli.test_run.log_stream_handler.logger"):
+ h.stop()
+
+ mock_srv.stop.assert_called_once()
+
+ def test_sets_is_running_false(self):
+ h, mock_srv = _make_handler()
+ h.is_running = True
+
+ with patch("th_cli.test_run.log_stream_handler.logger"):
+ h.stop()
+
+ assert h.is_running is False
+
+ def test_puts_none_sentinel_into_queue(self):
+ h, mock_srv = _make_handler()
+ h.is_running = True
+
+ with patch("th_cli.test_run.log_stream_handler.logger"):
+ h.stop()
+
+ assert h.log_queue.get_nowait() is None
+
+ def test_skips_sentinel_when_queue_full(self):
+ h, mock_srv = _make_handler()
+ h.is_running = True
+ # Fill the queue to capacity
+ for _ in range(h.log_queue.maxsize):
+ try:
+ h.log_queue.put_nowait("entry")
+ except queue.Full:
+ break
+
+ with patch("th_cli.test_run.log_stream_handler.logger"):
+ h.stop() # must not raise
+
+
+# ---------------------------------------------------------------------------
+# add_log_entry()
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.unit
+class TestAddLogEntry:
+ def test_noop_when_not_running(self):
+ h, _ = _make_handler()
+ h.is_running = False
+ h.add_log_entry(message="ignored")
+ assert h.log_queue.empty()
+
+ def test_adds_entry_to_queue_when_running(self):
+ h, _ = _make_handler()
+ h.is_running = True
+ h.add_log_entry(message="hello", level="INFO")
+ entry = h.log_queue.get_nowait()
+ assert entry["message"] == "hello"
+ assert entry["level"] == "INFO"
+
+ def test_auto_generates_timestamp_when_not_provided(self):
+ h, _ = _make_handler()
+ h.is_running = True
+ h.add_log_entry(message="msg")
+ entry = h.log_queue.get_nowait()
+ assert "timestamp" in entry
+ assert entry["timestamp"] is not None
+
+ def test_uses_provided_timestamp(self):
+ h, _ = _make_handler()
+ h.is_running = True
+ h.add_log_entry(message="msg", timestamp="2025-01-01T00:00:00")
+ entry = h.log_queue.get_nowait()
+ assert entry["timestamp"] == "2025-01-01T00:00:00"
+
+ def test_level_uppercased(self):
+ h, _ = _make_handler()
+ h.is_running = True
+ h.add_log_entry(message="msg", level="warning")
+ entry = h.log_queue.get_nowait()
+ assert entry["level"] == "WARNING"
+
+ def test_silently_drops_when_queue_full(self):
+ h, _ = _make_handler()
+ h.is_running = True
+ # Fill queue to max
+ for _ in range(h.log_queue.maxsize):
+ h.log_queue.put_nowait({"message": "x"})
+ h.add_log_entry(message="overflow") # must not raise
+
+
+# ---------------------------------------------------------------------------
+# _get_local_ip()
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.unit
+class TestGetLocalIp:
+ def test_returns_ip_string_on_success(self):
+ h, _ = _make_handler()
+ mock_socket = MagicMock()
+ mock_socket.getsockname.return_value = ("192.168.0.10", 0)
+
+ with patch("th_cli.test_run.log_stream_handler.socket.socket", return_value=mock_socket):
+ result = h._get_local_ip()
+
+ assert result == "192.168.0.10"
+
+ def test_returns_localhost_on_socket_error(self):
+ h, _ = _make_handler()
+ with patch("th_cli.test_run.log_stream_handler.socket.socket", side_effect=OSError("no network")):
+ result = h._get_local_ip()
+ assert result == "localhost"
+
+ def test_closes_socket_after_use(self):
+ h, _ = _make_handler()
+ mock_socket = MagicMock()
+ mock_socket.getsockname.return_value = ("10.0.0.1", 0)
+
+ with patch("th_cli.test_run.log_stream_handler.socket.socket", return_value=mock_socket):
+ h._get_local_ip()
+
+ mock_socket.close.assert_called_once()
+
+
+# ---------------------------------------------------------------------------
+# _get_log_viewer_url()
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.unit
+class TestGetLogViewerUrl:
+ def test_returns_http_url_with_port(self):
+ h, _ = _make_handler(port=8998)
+ with patch.object(h, "_get_local_ip", return_value="10.1.2.3"):
+ url = h._get_log_viewer_url()
+ assert url == "http://10.1.2.3:8998"
+
+ def test_uses_local_ip(self):
+ h, _ = _make_handler(port=9000)
+ with patch.object(h, "_get_local_ip", return_value="172.16.0.5"):
+ url = h._get_log_viewer_url()
+ assert "172.16.0.5" in url
diff --git a/tests/test_run/test_logging.py b/tests/test_run/test_logging.py
new file mode 100644
index 0000000..106f3df
--- /dev/null
+++ b/tests/test_run/test_logging.py
@@ -0,0 +1,237 @@
+#
+# 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.
+#
+"""Unit tests for th_cli/test_run/logging.py."""
+
+from unittest.mock import MagicMock, call, patch
+
+import pytest
+
+import th_cli.test_run.logging as logging_module
+from th_cli.test_run.logging import (
+ configure_logger_for_run,
+ get_log_stream_url,
+ stop_log_streaming,
+)
+
+
+# ---------------------------------------------------------------------------
+# Helpers
+# ---------------------------------------------------------------------------
+
+
+def _reset_global_handler():
+ """Reset the module-level _log_stream_handler to None between tests."""
+ logging_module._log_stream_handler = None
+
+
+# ---------------------------------------------------------------------------
+# configure_logger_for_run — without streaming
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.unit
+class TestConfigureLoggerForRunNoStreaming:
+ def setup_method(self):
+ _reset_global_handler()
+
+ def test_returns_string_path(self):
+ with patch("th_cli.test_run.logging.logger"):
+ result = configure_logger_for_run("my_run")
+ assert isinstance(result, str)
+
+ def test_path_contains_run_title(self):
+ with patch("th_cli.test_run.logging.logger"):
+ result = configure_logger_for_run("special_run_title")
+ assert "special_run_title" in result
+
+ def test_log_handler_remains_none_when_streaming_disabled(self):
+ with patch("th_cli.test_run.logging.logger"):
+ configure_logger_for_run("run", enable_log_streaming=False)
+ assert logging_module._log_stream_handler is None
+
+ def test_logger_remove_called(self):
+ with patch("th_cli.test_run.logging.logger") as mock_logger:
+ configure_logger_for_run("run")
+ mock_logger.remove.assert_called_once()
+
+ def test_logger_add_called_with_path(self):
+ with patch("th_cli.test_run.logging.logger") as mock_logger:
+ result = configure_logger_for_run("run")
+ # logger.add(log_path, ...) — first positional arg is the path
+ add_calls = mock_logger.add.call_args_list
+ assert len(add_calls) >= 1
+ first_call_path = add_calls[0][0][0]
+ assert "run" in first_call_path
+
+
+# ---------------------------------------------------------------------------
+# configure_logger_for_run — with streaming
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.unit
+class TestConfigureLoggerForRunWithStreaming:
+ def setup_method(self):
+ _reset_global_handler()
+
+ def test_sets_global_handler_when_streaming_enabled(self):
+ mock_handler = MagicMock()
+ mock_handler.start.return_value = "http://1.2.3.4:8998"
+ mock_handler.is_running = True
+
+ with patch("th_cli.test_run.logging.logger"):
+ with patch("th_cli.test_run.log_stream_handler.LogStreamHandler", return_value=mock_handler):
+ configure_logger_for_run("run", enable_log_streaming=True)
+
+ assert logging_module._log_stream_handler is mock_handler
+
+ def test_handler_start_called_with_title_and_path(self):
+ mock_handler = MagicMock()
+ mock_handler.start.return_value = "http://1.2.3.4:8998"
+
+ with patch("th_cli.test_run.logging.logger"):
+ with patch("th_cli.test_run.log_stream_handler.LogStreamHandler", return_value=mock_handler):
+ configure_logger_for_run("test_title", enable_log_streaming=True)
+
+ mock_handler.start.assert_called_once()
+ call_kwargs = mock_handler.start.call_args
+ assert call_kwargs[1].get("test_run_title") == "test_title" or \
+ call_kwargs[0][0] == "test_title"
+
+ def test_returns_path_string_with_streaming_enabled(self):
+ mock_handler = MagicMock()
+ mock_handler.start.return_value = "http://1.2.3.4:8998"
+
+ with patch("th_cli.test_run.logging.logger"):
+ with patch("th_cli.test_run.log_stream_handler.LogStreamHandler", return_value=mock_handler):
+ result = configure_logger_for_run("run", enable_log_streaming=True)
+
+ assert isinstance(result, str)
+
+ def test_graceful_fallback_when_handler_start_raises(self):
+ mock_handler = MagicMock()
+ mock_handler.start.side_effect = RuntimeError("port in use")
+
+ with patch("th_cli.test_run.logging.logger"):
+ with patch("th_cli.test_run.log_stream_handler.LogStreamHandler", return_value=mock_handler):
+ result = configure_logger_for_run("run", enable_log_streaming=True)
+
+ assert logging_module._log_stream_handler is None
+ assert isinstance(result, str)
+
+ def test_graceful_fallback_when_log_stream_handler_import_fails(self):
+ """When the LogStreamHandler module import fails inside the function, falls back gracefully."""
+ import importlib
+ import sys
+
+ # Remove the cached module to force a fresh import attempt inside the function
+ original = sys.modules.pop("th_cli.test_run.log_stream_handler", None)
+ try:
+ sys.modules["th_cli.test_run.log_stream_handler"] = None # type: ignore[assignment]
+ with patch("th_cli.test_run.logging.logger"):
+ result = configure_logger_for_run("run", enable_log_streaming=True)
+ finally:
+ if original is not None:
+ sys.modules["th_cli.test_run.log_stream_handler"] = original
+ elif "th_cli.test_run.log_stream_handler" in sys.modules:
+ del sys.modules["th_cli.test_run.log_stream_handler"]
+
+ assert isinstance(result, str)
+ assert logging_module._log_stream_handler is None
+
+
+# ---------------------------------------------------------------------------
+# stop_log_streaming
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.unit
+class TestStopLogStreaming:
+ def setup_method(self):
+ _reset_global_handler()
+
+ def test_noop_when_no_handler(self):
+ stop_log_streaming() # must not raise
+ assert logging_module._log_stream_handler is None
+
+ def test_calls_stop_on_handler(self):
+ mock_handler = MagicMock()
+ logging_module._log_stream_handler = mock_handler
+
+ with patch("th_cli.test_run.logging.logger"):
+ stop_log_streaming()
+
+ mock_handler.stop.assert_called_once()
+
+ def test_sets_global_to_none_after_stop(self):
+ mock_handler = MagicMock()
+ logging_module._log_stream_handler = mock_handler
+
+ with patch("th_cli.test_run.logging.logger"):
+ stop_log_streaming()
+
+ assert logging_module._log_stream_handler is None
+
+ def test_sets_global_to_none_even_when_stop_raises(self):
+ mock_handler = MagicMock()
+ mock_handler.stop.side_effect = Exception("already stopped")
+ logging_module._log_stream_handler = mock_handler
+
+ with patch("th_cli.test_run.logging.logger"):
+ stop_log_streaming() # must not raise
+
+ assert logging_module._log_stream_handler is None
+
+
+# ---------------------------------------------------------------------------
+# get_log_stream_url
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.unit
+class TestGetLogStreamUrl:
+ def setup_method(self):
+ _reset_global_handler()
+
+ def test_returns_none_when_no_handler(self):
+ result = get_log_stream_url()
+ assert result is None
+
+ def test_returns_none_when_handler_not_running(self):
+ mock_handler = MagicMock()
+ mock_handler.is_running = False
+ logging_module._log_stream_handler = mock_handler
+
+ result = get_log_stream_url()
+ assert result is None
+
+ def test_returns_url_when_handler_is_running(self):
+ mock_handler = MagicMock()
+ mock_handler.is_running = True
+ mock_handler._get_log_viewer_url.return_value = "http://10.0.0.1:8998"
+ logging_module._log_stream_handler = mock_handler
+
+ result = get_log_stream_url()
+ assert result == "http://10.0.0.1:8998"
+
+ def test_calls_get_log_viewer_url_on_handler(self):
+ mock_handler = MagicMock()
+ mock_handler.is_running = True
+ mock_handler._get_log_viewer_url.return_value = "http://localhost:8998"
+ logging_module._log_stream_handler = mock_handler
+
+ get_log_stream_url()
+ mock_handler._get_log_viewer_url.assert_called_once()
diff --git a/tests/test_run/test_logs_http_server.py b/tests/test_run/test_logs_http_server.py
new file mode 100644
index 0000000..106f310
--- /dev/null
+++ b/tests/test_run/test_logs_http_server.py
@@ -0,0 +1,454 @@
+#
+# 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.
+#
+"""Unit tests for th_cli/test_run/logs_http_server.py."""
+
+import json
+import queue
+import threading
+import time
+from io import BytesIO
+from pathlib import Path
+from unittest.mock import MagicMock, patch
+
+import pytest
+
+from th_cli.test_run.logs_http_server import (
+ ENDPOINT_DOWNLOAD_LOGS,
+ ENDPOINT_LOGS_STREAM,
+ ENDPOINT_ROOT,
+ LogStreamingHandler,
+ LogsHTTPServer,
+)
+
+
+# ---------------------------------------------------------------------------
+# Helpers: build a LogStreamingHandler without a live socket
+# ---------------------------------------------------------------------------
+
+
+def _make_handler(path="/", server_attrs=None):
+ """Construct a LogStreamingHandler bypassing __init__."""
+ handler = LogStreamingHandler.__new__(LogStreamingHandler)
+ handler.path = path
+
+ mock_server = MagicMock()
+ for attr, value in (server_attrs or {}).items():
+ setattr(mock_server, attr, value)
+ handler.server = mock_server
+
+ handler.wfile = BytesIO()
+ handler.rfile = BytesIO()
+
+ handler._response_code = None
+ handler._headers_sent = {}
+ handler._error_code = None
+
+ handler.send_response = lambda code, msg=None: setattr(handler, "_response_code", code)
+ handler.send_header = lambda k, v: handler._headers_sent.__setitem__(k, v)
+ handler.end_headers = lambda: None
+ handler.send_error = lambda code, msg=None: setattr(handler, "_error_code", code)
+
+ return handler
+
+
+# ---------------------------------------------------------------------------
+# do_GET routing
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.unit
+class TestLogStreamingHandlerRouting:
+ def test_root_calls_serve_log_viewer(self):
+ h = _make_handler(path=ENDPOINT_ROOT)
+ with patch.object(h, "serve_log_viewer") as mock_fn:
+ h.do_GET()
+ mock_fn.assert_called_once()
+
+ def test_stream_endpoint_calls_stream_logs(self):
+ h = _make_handler(path=ENDPOINT_LOGS_STREAM)
+ with patch.object(h, "stream_logs") as mock_fn:
+ h.do_GET()
+ mock_fn.assert_called_once()
+
+ def test_download_endpoint_calls_download_logs(self):
+ h = _make_handler(path=ENDPOINT_DOWNLOAD_LOGS)
+ with patch.object(h, "download_logs") as mock_fn:
+ h.do_GET()
+ mock_fn.assert_called_once()
+
+ def test_unknown_path_sends_404(self):
+ h = _make_handler(path="/nonexistent")
+ with patch("th_cli.test_run.logs_http_server.logger"):
+ h.do_GET()
+ assert h._error_code == 404
+
+
+# ---------------------------------------------------------------------------
+# download_logs()
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.unit
+class TestDownloadLogs:
+ def test_404_when_no_log_file_path(self):
+ h = _make_handler(server_attrs={"log_file_path": None})
+ with patch("th_cli.test_run.logs_http_server.logger"):
+ h.download_logs()
+ assert h._error_code == 404
+
+ def test_404_when_file_does_not_exist(self, tmp_path):
+ h = _make_handler(server_attrs={"log_file_path": str(tmp_path / "missing.log")})
+ with patch("th_cli.test_run.logs_http_server.logger"):
+ h.download_logs()
+ assert h._error_code == 404
+
+ def test_200_and_correct_headers_for_existing_file(self, tmp_path):
+ log_file = tmp_path / "test.log"
+ log_file.write_bytes(b"log content here")
+ h = _make_handler(server_attrs={"log_file_path": str(log_file)})
+
+ with patch("th_cli.test_run.logs_http_server.logger"):
+ h.download_logs()
+
+ assert h._response_code == 200
+ assert h._headers_sent.get("Content-Type") == "text/plain; charset=utf-8"
+ assert "Content-Disposition" in h._headers_sent
+
+ def test_file_content_written_to_wfile(self, tmp_path):
+ content = b"line 1\nline 2\n"
+ log_file = tmp_path / "run.log"
+ log_file.write_bytes(content)
+ h = _make_handler(server_attrs={"log_file_path": str(log_file)})
+
+ with patch("th_cli.test_run.logs_http_server.logger"):
+ h.download_logs()
+
+ assert h.wfile.getvalue() == content
+
+ def test_handles_broken_pipe_gracefully(self, tmp_path):
+ log_file = tmp_path / "run.log"
+ log_file.write_bytes(b"data")
+ h = _make_handler(server_attrs={"log_file_path": str(log_file)})
+ # Make wfile.write raise BrokenPipeError
+ h.wfile = MagicMock()
+ h.wfile.write.side_effect = BrokenPipeError
+
+ with patch("th_cli.test_run.logs_http_server.logger"):
+ h.download_logs() # must not raise
+
+
+# ---------------------------------------------------------------------------
+# _send_sse_event()
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.unit
+class TestSendSseEvent:
+ def test_returns_true_on_success(self):
+ h = _make_handler()
+ result = h._send_sse_event("test", {"key": "value"})
+ assert result is True
+
+ def test_writes_sse_format_to_wfile(self):
+ h = _make_handler()
+ h._send_sse_event("myevent", {"msg": "hello"})
+ output = h.wfile.getvalue().decode("utf-8")
+ assert "event: myevent" in output
+ assert "hello" in output
+ assert output.endswith("\n\n")
+
+ def test_returns_false_on_broken_pipe(self):
+ h = _make_handler()
+ h.wfile = MagicMock()
+ h.wfile.write.side_effect = BrokenPipeError
+ result = h._send_sse_event("ev", {})
+ assert result is False
+
+ def test_returns_false_on_connection_reset(self):
+ h = _make_handler()
+ h.wfile = MagicMock()
+ h.wfile.write.side_effect = ConnectionResetError
+ result = h._send_sse_event("ev", {})
+ assert result is False
+
+ def test_data_is_valid_json(self):
+ h = _make_handler()
+ h._send_sse_event("ev", {"a": 1, "b": "two"})
+ output = h.wfile.getvalue().decode("utf-8")
+ # extract the data line
+ data_line = [ln for ln in output.split("\n") if ln.startswith("data:")][0]
+ parsed = json.loads(data_line[len("data:"):].strip())
+ assert parsed == {"a": 1, "b": "two"}
+
+
+# ---------------------------------------------------------------------------
+# stream_logs()
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.unit
+class TestStreamLogs:
+ def test_sends_sse_headers(self):
+ q = queue.Queue()
+ q.put(None) # immediate end-of-stream
+ h = _make_handler(server_attrs={"log_queue": q})
+
+ with patch("th_cli.test_run.logs_http_server.logger"):
+ h.stream_logs()
+
+ assert h._response_code == 200
+ assert h._headers_sent.get("Content-Type") == "text/event-stream"
+
+ def test_sends_end_event_on_none_sentinel(self):
+ q = queue.Queue()
+ q.put(None)
+ h = _make_handler(server_attrs={"log_queue": q})
+ sent_events = []
+ original_send = h._send_sse_event
+ h._send_sse_event = lambda ev, data: sent_events.append(ev) or True
+
+ with patch("th_cli.test_run.logs_http_server.logger"):
+ h.stream_logs()
+
+ assert "end" in sent_events
+
+ def test_streams_log_entries_from_queue(self):
+ q = queue.Queue()
+ q.put({"message": "hello", "level": "INFO", "timestamp": "2025-01-01"})
+ q.put(None)
+ h = _make_handler(server_attrs={"log_queue": q})
+ sent_events = []
+ h._send_sse_event = lambda ev, data: sent_events.append((ev, data)) or True
+
+ with patch("th_cli.test_run.logs_http_server.logger"):
+ h.stream_logs()
+
+ log_events = [d for ev, d in sent_events if ev == "log"]
+ assert len(log_events) == 1
+ assert log_events[0]["message"] == "hello"
+
+ def test_stops_when_send_sse_returns_false(self):
+ q = queue.Queue()
+ q.put({"message": "msg", "level": "INFO", "timestamp": "t"})
+ q.put({"message": "msg2", "level": "INFO", "timestamp": "t"})
+ h = _make_handler(server_attrs={"log_queue": q})
+ call_count = [0]
+
+ def _send(ev, data):
+ call_count[0] += 1
+ if ev == "connected":
+ return True
+ return False # disconnect immediately
+
+ h._send_sse_event = _send
+
+ with patch("th_cli.test_run.logs_http_server.logger"):
+ h.stream_logs()
+
+ # Should not have kept reading after disconnect
+ assert call_count[0] <= 3
+
+ def test_no_log_queue_on_server_returns_early(self):
+ h = _make_handler(server_attrs={"log_queue": None})
+ with patch("th_cli.test_run.logs_http_server.logger"):
+ h.stream_logs()
+ # Should have sent 200 headers but not crashed
+ assert h._response_code == 200
+
+ def test_debug_log_at_100_entries(self):
+ """Covers line 134: `if sent_count % 100 == 0` debug message."""
+ q = queue.Queue()
+ # Put 100 log entries then sentinel
+ for i in range(100):
+ q.put({"message": f"msg{i}", "level": "INFO", "timestamp": "t"})
+ q.put(None)
+
+ h = _make_handler(server_attrs={"log_queue": q})
+ with patch("th_cli.test_run.logs_http_server.logger") as mock_logger:
+ h.stream_logs()
+
+ # 100 entries should have triggered the debug log
+ mock_logger.debug.assert_called()
+
+
+# ---------------------------------------------------------------------------
+# serve_log_viewer()
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.unit
+class TestServeLogViewer:
+ def test_returns_200(self):
+ h = _make_handler(server_attrs={"test_run_title": "My Run"})
+ with patch("th_cli.test_run.logs_http_server.logger"):
+ with patch("builtins.open", side_effect=FileNotFoundError("no template")):
+ h.serve_log_viewer()
+ assert h._response_code == 200
+
+ def test_sets_html_content_type(self):
+ h = _make_handler(server_attrs={"test_run_title": "My Run"})
+ with patch("th_cli.test_run.logs_http_server.logger"):
+ with patch("builtins.open", side_effect=FileNotFoundError("no template")):
+ h.serve_log_viewer()
+ assert "text/html" in h._headers_sent.get("Content-Type", "")
+
+ def test_sets_no_cache_headers(self):
+ h = _make_handler(server_attrs={"test_run_title": "My Run"})
+ with patch("th_cli.test_run.logs_http_server.logger"):
+ with patch("builtins.open", side_effect=FileNotFoundError("no template")):
+ h.serve_log_viewer()
+ assert "no-store" in h._headers_sent.get("Cache-Control", "")
+
+ def test_fallback_html_on_template_error(self):
+ h = _make_handler(server_attrs={"test_run_title": "FallbackRun"})
+ with patch("th_cli.test_run.logs_http_server.logger"):
+ with patch("builtins.open", side_effect=FileNotFoundError("no template")):
+ h.serve_log_viewer()
+ content = h.wfile.getvalue().decode("utf-8")
+ assert "Error" in content or "FallbackRun" in content
+
+ def test_uses_template_when_available(self, tmp_path):
+ template = tmp_path / "log_viewer.html"
+ template.write_text("
{test_run_title}")
+ h = _make_handler(server_attrs={"test_run_title": "TemplateRun"})
+
+ with patch("th_cli.test_run.logs_http_server.logger"):
+ with patch("th_cli.test_run.logs_http_server.Path") as mock_path_cls:
+ mock_path_instance = MagicMock()
+ mock_path_instance.__truediv__ = MagicMock(return_value=template)
+ mock_path_cls.return_value = mock_path_instance
+ # Use the real open for the actual template file
+ h.serve_log_viewer()
+
+ assert h._response_code == 200
+
+
+# ---------------------------------------------------------------------------
+# log_message() — should be a no-op
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.unit
+class TestLogMessage:
+ def test_does_not_raise(self):
+ h = _make_handler()
+ h.log_message("GET /path HTTP/1.1", "200") # must not raise
+
+
+# ---------------------------------------------------------------------------
+# LogsHTTPServer.__init__
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.unit
+class TestLogsHTTPServerInit:
+ def test_port_stored(self):
+ srv = LogsHTTPServer(port=9999)
+ assert srv.port == 9999
+
+ def test_default_port(self):
+ srv = LogsHTTPServer()
+ assert srv.port == 8998
+
+ def test_server_initially_none(self):
+ srv = LogsHTTPServer()
+ assert srv.server is None
+
+ def test_server_thread_initially_none(self):
+ srv = LogsHTTPServer()
+ assert srv.server_thread is None
+
+
+# ---------------------------------------------------------------------------
+# LogsHTTPServer.start()
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.unit
+class TestLogsHTTPServerStart:
+ def test_creates_threading_http_server(self):
+ srv = LogsHTTPServer(port=0)
+ q = queue.Queue()
+
+ with patch("th_cli.test_run.logs_http_server.ThreadingHTTPServer") as mock_cls:
+ mock_ths = MagicMock()
+ mock_cls.return_value = mock_ths
+ with patch("th_cli.test_run.logs_http_server.threading.Thread") as mock_thread_cls:
+ mock_thread = MagicMock()
+ mock_thread_cls.return_value = mock_thread
+ with patch("th_cli.test_run.logs_http_server.logger"):
+ srv.start(log_queue=q, test_run_title="Run")
+
+ mock_cls.assert_called_once()
+ mock_thread.start.assert_called_once()
+
+ def test_sets_server_attributes(self):
+ srv = LogsHTTPServer(port=0)
+ q = queue.Queue()
+
+ with patch("th_cli.test_run.logs_http_server.ThreadingHTTPServer") as mock_cls:
+ mock_ths = MagicMock()
+ mock_cls.return_value = mock_ths
+ with patch("th_cli.test_run.logs_http_server.threading.Thread", return_value=MagicMock()):
+ with patch("th_cli.test_run.logs_http_server.logger"):
+ srv.start(log_queue=q, test_run_title="MyTitle", local_ip="1.2.3.4", log_file_path="/tmp/f.log")
+
+ assert mock_ths.log_queue is q
+ assert mock_ths.test_run_title == "MyTitle"
+ assert mock_ths.local_ip == "1.2.3.4"
+ assert mock_ths.log_file_path == "/tmp/f.log"
+
+ def test_propagates_oserror(self):
+ srv = LogsHTTPServer(port=0)
+ with patch("th_cli.test_run.logs_http_server.ThreadingHTTPServer", side_effect=OSError("port in use")):
+ with patch("th_cli.test_run.logs_http_server.logger"):
+ with pytest.raises(OSError):
+ srv.start(log_queue=queue.Queue(), test_run_title="run")
+
+
+# ---------------------------------------------------------------------------
+# LogsHTTPServer.stop()
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.unit
+class TestLogsHTTPServerStop:
+ def test_noop_when_server_is_none(self):
+ srv = LogsHTTPServer()
+ with patch("th_cli.test_run.logs_http_server.logger"):
+ srv.stop() # must not raise
+ assert srv.server is None
+
+ def test_calls_shutdown_on_server(self):
+ srv = LogsHTTPServer()
+ mock_ths = MagicMock()
+ srv.server = mock_ths
+
+ with patch("th_cli.test_run.logs_http_server.logger"):
+ srv.stop()
+
+ mock_ths.shutdown.assert_called_once()
+
+ def test_clears_server_and_thread_refs(self):
+ srv = LogsHTTPServer()
+ srv.server = MagicMock()
+ srv.server_thread = MagicMock()
+
+ with patch("th_cli.test_run.logs_http_server.logger"):
+ srv.stop()
+
+ assert srv.server is None
+ assert srv.server_thread is None
diff --git a/tests/test_run/test_prompt_manager_additional.py b/tests/test_run/test_prompt_manager_additional.py
new file mode 100644
index 0000000..5c958de
--- /dev/null
+++ b/tests/test_run/test_prompt_manager_additional.py
@@ -0,0 +1,656 @@
+#
+# 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.
+#
+"""Additional coverage tests for th_cli/test_run/prompt_manager.py.
+
+Covers: handle_prompt routing, _get_local_ip, _get_video_handler,
+_cleanup_video_handler, __handle_message_prompt, _handle_image_verification_prompt,
+_handle_two_way_talk_prompt, _handle_push_av_stream_prompt,
+__handle_options_prompt, __handle_text_prompt,
+__upload_file_and_send_response, __valid_text_input, __valid_file_upload.
+"""
+
+import asyncio
+import os
+import queue
+from unittest.mock import AsyncMock, MagicMock, Mock, patch
+
+import pytest
+
+import th_cli.test_run.prompt_manager as pm_module
+from th_cli.shared_constants import MessageTypeEnum
+from th_cli.test_run.prompt_manager import (
+ _cleanup_video_handler,
+ _get_local_ip,
+ _get_video_handler,
+ handle_prompt,
+)
+from th_cli.test_run.socket_schemas import (
+ ImageVerificationPromptRequest,
+ MessagePromptRequest,
+ OptionsSelectPromptRequest,
+ PushAVStreamVerificationRequest,
+ PromptRequest,
+ StreamVerificationPromptRequest,
+ TextInputPromptRequest,
+ TwoWayTalkVerificationRequest,
+)
+
+
+# ---------------------------------------------------------------------------
+# Helpers
+# ---------------------------------------------------------------------------
+
+
+def _make_options_prompt(**kwargs):
+ defaults = dict(prompt="Pick one", timeout=30, message_id=1, options={"PASS": 1, "FAIL": 2})
+ defaults.update(kwargs)
+ return OptionsSelectPromptRequest(**defaults)
+
+
+def _make_image_prompt(**kwargs):
+ defaults = dict(
+ prompt="Verify image", timeout=30, message_id=2,
+ options={"PASS": 1, "FAIL": 2}, image_hex_str="ffd8ffe0"
+ )
+ defaults.update(kwargs)
+ return ImageVerificationPromptRequest(**defaults)
+
+
+def _make_twt_prompt(**kwargs):
+ defaults = dict(prompt="Verify audio", timeout=30, message_id=3, options={"PASS": 1, "FAIL": 2})
+ defaults.update(kwargs)
+ return TwoWayTalkVerificationRequest(**defaults)
+
+
+def _make_push_av_prompt(**kwargs):
+ defaults = dict(prompt="Verify stream", timeout=30, message_id=4, options={"PASS": 1, "FAIL": 2})
+ defaults.update(kwargs)
+ return PushAVStreamVerificationRequest(**defaults)
+
+
+def _make_stream_prompt(**kwargs):
+ defaults = dict(prompt="Verify video", timeout=30, message_id=5, options={"PASS": 1, "FAIL": 2})
+ defaults.update(kwargs)
+ return StreamVerificationPromptRequest(**defaults)
+
+
+def _make_text_prompt(**kwargs):
+ defaults = dict(prompt="Enter text", timeout=30, message_id=6)
+ defaults.update(kwargs)
+ return TextInputPromptRequest(**defaults)
+
+
+def _make_message_prompt(**kwargs):
+ defaults = dict(prompt="Please acknowledge", timeout=30, message_id=7)
+ defaults.update(kwargs)
+ return MessagePromptRequest(**defaults)
+
+
+# ---------------------------------------------------------------------------
+# _get_local_ip
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.unit
+class TestGetLocalIp:
+ def test_returns_ip_string_on_success(self):
+ mock_socket = MagicMock()
+ mock_socket.__enter__ = MagicMock(return_value=mock_socket)
+ mock_socket.__exit__ = MagicMock(return_value=False)
+ mock_socket.getsockname.return_value = ("192.168.0.10", 0)
+
+ with patch("th_cli.test_run.prompt_manager.socket.socket", return_value=mock_socket):
+ result = _get_local_ip()
+
+ assert result == "192.168.0.10"
+
+ def test_returns_localhost_on_exception(self):
+ with patch("th_cli.test_run.prompt_manager.socket.socket", side_effect=OSError("no network")):
+ result = _get_local_ip()
+ assert result == "localhost"
+
+
+# ---------------------------------------------------------------------------
+# _get_video_handler / _cleanup_video_handler
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.unit
+class TestGetVideoHandler:
+ def setup_method(self):
+ pm_module._video_handler_instance = None
+
+ def test_creates_new_instance_when_none(self):
+ mock_handler = MagicMock()
+ # CameraStreamHandler is imported lazily inside _get_video_handler
+ # via `from .camera import CameraStreamHandler`, so patch the source
+ with patch("th_cli.test_run.camera.CameraStreamHandler", return_value=mock_handler):
+ result = _get_video_handler()
+ assert result is mock_handler
+
+ def test_returns_existing_instance(self):
+ mock_handler = MagicMock()
+ pm_module._video_handler_instance = mock_handler
+ result = _get_video_handler()
+ assert result is mock_handler
+
+ def teardown_method(self):
+ pm_module._video_handler_instance = None
+
+
+@pytest.mark.unit
+class TestCleanupVideoHandler:
+ def setup_method(self):
+ pm_module._video_handler_instance = None
+
+ @pytest.mark.asyncio
+ async def test_stops_existing_handler(self):
+ mock_handler = MagicMock()
+ mock_handler.stop_video_capture_and_stream = AsyncMock()
+ pm_module._video_handler_instance = mock_handler
+
+ await _cleanup_video_handler()
+ mock_handler.stop_video_capture_and_stream.assert_called_once()
+
+ @pytest.mark.asyncio
+ async def test_noop_when_no_handler(self):
+ pm_module._video_handler_instance = None
+ await _cleanup_video_handler() # must not raise
+
+ @pytest.mark.asyncio
+ async def test_ignores_stop_exception(self):
+ mock_handler = MagicMock()
+ mock_handler.stop_video_capture_and_stream = AsyncMock(side_effect=RuntimeError("boom"))
+ pm_module._video_handler_instance = mock_handler
+ await _cleanup_video_handler() # must not raise
+
+ def teardown_method(self):
+ pm_module._video_handler_instance = None
+
+
+# ---------------------------------------------------------------------------
+# handle_prompt — routing
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.unit
+class TestHandlePromptRouting:
+ @pytest.mark.asyncio
+ async def test_routes_image_verification_by_type(self):
+ prompt = _make_options_prompt()
+ with patch("th_cli.test_run.prompt_manager._handle_image_verification_prompt", new_callable=AsyncMock) as m:
+ with patch("th_cli.test_run.prompt_manager.click.echo"):
+ await handle_prompt(
+ socket=AsyncMock(), request=prompt,
+ message_type=MessageTypeEnum.IMAGE_VERIFICATION_REQUEST,
+ )
+ m.assert_called_once()
+
+ @pytest.mark.asyncio
+ async def test_routes_image_verification_by_instance(self):
+ prompt = _make_image_prompt()
+ with patch("th_cli.test_run.prompt_manager._handle_image_verification_prompt", new_callable=AsyncMock) as m:
+ with patch("th_cli.test_run.prompt_manager.click.echo"):
+ await handle_prompt(socket=AsyncMock(), request=prompt)
+ m.assert_called_once()
+
+ @pytest.mark.asyncio
+ async def test_routes_two_way_talk_by_type(self):
+ prompt = _make_options_prompt()
+ with patch("th_cli.test_run.prompt_manager._handle_two_way_talk_prompt", new_callable=AsyncMock) as m:
+ with patch("th_cli.test_run.prompt_manager.click.echo"):
+ await handle_prompt(
+ socket=AsyncMock(), request=prompt,
+ message_type=MessageTypeEnum.TWO_WAY_TALK_VERIFICATION_REQUEST,
+ )
+ m.assert_called_once()
+
+ @pytest.mark.asyncio
+ async def test_routes_stream_verification_by_instance(self):
+ """StreamVerificationPromptRequest routes to __handle_stream_verification_prompt.
+ The handler is a module-level private function; patch _get_video_handler so it
+ returns a mock that completes the flow without real I/O."""
+ stream_prompt = _make_stream_prompt()
+ mock_socket = AsyncMock()
+ with patch("th_cli.test_run.prompt_manager._send_prompt_response", new_callable=AsyncMock) as mock_send:
+ with patch("th_cli.test_run.prompt_manager._get_local_ip", return_value="10.0.0.1"):
+ with patch("th_cli.test_run.prompt_manager.click.echo"):
+ vh = MagicMock()
+ vh.http_server = MagicMock()
+ vh.http_server.port = 8999
+ vh.set_prompt_data = MagicMock()
+ vh.start_video_capture_and_stream = AsyncMock()
+ vh.wait_for_stream_ready = AsyncMock(return_value=True)
+ vh.wait_for_user_response = AsyncMock(return_value=1)
+ vh.stop_video_capture_and_stream = AsyncMock()
+ pm_module._video_handler_instance = vh
+ await handle_prompt(socket=mock_socket, request=stream_prompt)
+ pm_module._video_handler_instance = None
+
+ mock_send.assert_called_once()
+
+ @pytest.mark.asyncio
+ async def test_routes_push_av_by_type(self):
+ prompt = _make_options_prompt()
+ with patch("th_cli.test_run.prompt_manager._handle_push_av_stream_prompt", new_callable=AsyncMock) as m:
+ with patch("th_cli.test_run.prompt_manager.click.echo"):
+ await handle_prompt(
+ socket=AsyncMock(), request=prompt,
+ message_type=MessageTypeEnum.PUSH_AV_STREAM_VERIFICATION_REQUEST,
+ )
+ m.assert_called_once()
+
+ @pytest.mark.asyncio
+ async def test_routes_message_request_by_type(self):
+ prompt = _make_message_prompt()
+ mock_socket = AsyncMock()
+ mock_socket.send = AsyncMock()
+ with patch("th_cli.test_run.prompt_manager._send_prompt_response", new_callable=AsyncMock):
+ with patch("th_cli.test_run.prompt_manager.click.echo"):
+ await handle_prompt(
+ socket=mock_socket, request=prompt,
+ message_type=MessageTypeEnum.MESSAGE_REQUEST,
+ )
+
+ @pytest.mark.asyncio
+ async def test_routes_message_prompt_by_instance(self):
+ prompt = _make_message_prompt()
+ mock_socket = AsyncMock()
+ with patch("th_cli.test_run.prompt_manager._send_prompt_response", new_callable=AsyncMock):
+ with patch("th_cli.test_run.prompt_manager.click.echo"):
+ await handle_prompt(socket=mock_socket, request=prompt)
+
+ @pytest.mark.asyncio
+ async def test_echoes_error_for_unsupported_prompt(self):
+ prompt = PromptRequest(prompt="plain", timeout=10, message_id=99)
+ with patch("th_cli.test_run.prompt_manager.click.echo") as mock_echo:
+ await handle_prompt(socket=AsyncMock(), request=prompt)
+ output = " ".join(str(a) for call in mock_echo.call_args_list for a in call[0])
+ assert "Unsupported" in output
+
+
+# ---------------------------------------------------------------------------
+# _handle_image_verification_prompt
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.unit
+class TestHandleImageVerificationPrompt:
+ @pytest.mark.asyncio
+ async def test_sends_response_on_success(self):
+ prompt = _make_image_prompt(image_hex_str="ffd8")
+ mock_handler = MagicMock()
+ mock_handler.http_server = MagicMock()
+ mock_handler.http_server.port = 8999
+ mock_handler.start_image_server = AsyncMock()
+ mock_handler.wait_for_user_response = AsyncMock(return_value=1)
+ mock_handler.stop_image_server = MagicMock()
+ mock_socket = AsyncMock()
+
+ with patch("th_cli.test_run.prompt_manager.ImageVerificationHandler", return_value=mock_handler):
+ with patch("th_cli.test_run.prompt_manager._send_prompt_response", new_callable=AsyncMock) as mock_send:
+ with patch("th_cli.test_run.prompt_manager._get_local_ip", return_value="1.2.3.4"):
+ with patch("th_cli.test_run.prompt_manager.click.echo"):
+ await pm_module._handle_image_verification_prompt(socket=mock_socket, prompt=prompt)
+
+ mock_send.assert_called_once()
+
+ @pytest.mark.asyncio
+ async def test_no_response_on_timeout(self):
+ prompt = _make_image_prompt(image_hex_str="ffd8")
+ mock_handler = MagicMock()
+ mock_handler.http_server = MagicMock()
+ mock_handler.http_server.port = 8999
+ mock_handler.start_image_server = AsyncMock()
+ mock_handler.wait_for_user_response = AsyncMock(return_value=None)
+ mock_handler.stop_image_server = MagicMock()
+ mock_socket = AsyncMock()
+
+ with patch("th_cli.test_run.prompt_manager.ImageVerificationHandler", return_value=mock_handler):
+ with patch("th_cli.test_run.prompt_manager._send_prompt_response", new_callable=AsyncMock) as mock_send:
+ with patch("th_cli.test_run.prompt_manager._get_local_ip", return_value="1.2.3.4"):
+ with patch("th_cli.test_run.prompt_manager.click.echo"):
+ await pm_module._handle_image_verification_prompt(socket=mock_socket, prompt=prompt)
+
+ mock_send.assert_not_called()
+
+ @pytest.mark.asyncio
+ async def test_handles_exception_gracefully(self):
+ prompt = _make_image_prompt(image_hex_str="not_valid_hex_at_all_ZZZZ")
+ mock_socket = AsyncMock()
+ with patch("th_cli.test_run.prompt_manager.click.echo"):
+ await pm_module._handle_image_verification_prompt(socket=mock_socket, prompt=prompt)
+ # Must not raise
+
+
+# ---------------------------------------------------------------------------
+# _handle_two_way_talk_prompt
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.unit
+class TestHandleTwoWayTalkPrompt:
+ @pytest.mark.asyncio
+ async def test_sends_response_on_success(self):
+ prompt = _make_twt_prompt()
+ mock_handler = MagicMock()
+ mock_handler.wait_for_user_response = AsyncMock(return_value=1)
+ mock_handler.stop = MagicMock()
+ mock_socket = AsyncMock()
+
+ with patch("th_cli.test_run.prompt_manager._send_prompt_response", new_callable=AsyncMock) as mock_send:
+ with patch("th_cli.test_run.prompt_manager._get_local_ip", return_value="10.0.0.1"):
+ with patch("th_cli.test_run.prompt_manager.click.echo"):
+ await pm_module._handle_two_way_talk_prompt(
+ socket=mock_socket, prompt=prompt, handler=mock_handler
+ )
+
+ mock_send.assert_called_once()
+ mock_handler.stop.assert_called_once()
+
+ @pytest.mark.asyncio
+ async def test_stops_handler_on_timeout(self):
+ prompt = _make_twt_prompt()
+ mock_handler = MagicMock()
+ mock_handler.wait_for_user_response = AsyncMock(return_value=None)
+ mock_handler.stop = MagicMock()
+ mock_socket = AsyncMock()
+
+ with patch("th_cli.test_run.prompt_manager._send_prompt_response", new_callable=AsyncMock):
+ with patch("th_cli.test_run.prompt_manager._get_local_ip", return_value="10.0.0.1"):
+ with patch("th_cli.test_run.prompt_manager.click.echo"):
+ await pm_module._handle_two_way_talk_prompt(
+ socket=mock_socket, prompt=prompt, handler=mock_handler
+ )
+
+ mock_handler.stop.assert_called_once()
+
+ @pytest.mark.asyncio
+ async def test_creates_fallback_handler_when_none(self):
+ prompt = _make_twt_prompt()
+ mock_socket = AsyncMock()
+ mock_twt = MagicMock()
+ mock_twt.wait_for_user_response = AsyncMock(return_value=1)
+ mock_twt.stop = MagicMock()
+
+ with patch("th_cli.test_run.prompt_manager.TwoWayTalkHandler", return_value=mock_twt):
+ with patch("th_cli.test_run.prompt_manager._send_prompt_response", new_callable=AsyncMock):
+ with patch("th_cli.test_run.prompt_manager._get_local_ip", return_value="10.0.0.1"):
+ with patch("th_cli.test_run.prompt_manager.click.echo"):
+ await pm_module._handle_two_way_talk_prompt(
+ socket=mock_socket, prompt=prompt, handler=None
+ )
+
+ mock_twt.start_server_only.assert_called_once()
+
+
+# ---------------------------------------------------------------------------
+# _handle_push_av_stream_prompt
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.unit
+class TestHandlePushAvStreamPrompt:
+ @pytest.mark.asyncio
+ async def test_sends_response_on_user_answer(self):
+ prompt = _make_push_av_prompt()
+ mock_socket = AsyncMock()
+ mock_http_server = MagicMock()
+ mock_http_server.port = 8999
+
+ def fake_start(**kwargs):
+ pass
+
+ mock_http_server.start = fake_start
+ mock_http_server.stop = MagicMock()
+
+ with patch("th_cli.test_run.prompt_manager.CameraHTTPServer", return_value=mock_http_server):
+ with patch("th_cli.test_run.prompt_manager._send_prompt_response", new_callable=AsyncMock) as mock_send:
+ with patch("th_cli.test_run.prompt_manager._get_local_ip", return_value="1.2.3.4"):
+ with patch("th_cli.test_run.prompt_manager.click.echo"):
+ # Pre-queue a response
+ captured_queue = None
+ original_queue_cls = queue.Queue
+
+ def fake_queue():
+ q = original_queue_cls()
+ q.put_nowait(1)
+ return q
+
+ with patch("th_cli.test_run.prompt_manager.queue.Queue", side_effect=fake_queue):
+ await pm_module._handle_push_av_stream_prompt(
+ socket=mock_socket, prompt=prompt
+ )
+
+ mock_send.assert_called_once()
+
+ @pytest.mark.asyncio
+ async def test_no_response_when_missing_options(self):
+ from pydantic import ValidationError
+ # Can't construct with empty options — just test graceful handling via exception
+ prompt = _make_push_av_prompt()
+ prompt_no_opts = MagicMock(spec=PushAVStreamVerificationRequest)
+ prompt_no_opts.options = {}
+ mock_socket = AsyncMock()
+
+ with patch("th_cli.test_run.prompt_manager._send_prompt_response", new_callable=AsyncMock) as mock_send:
+ with patch("th_cli.test_run.prompt_manager.click.echo"):
+ await pm_module._handle_push_av_stream_prompt(socket=mock_socket, prompt=prompt_no_opts)
+
+ mock_send.assert_not_called()
+
+
+# ---------------------------------------------------------------------------
+# __upload_file_and_send_response (module-private)
+# ---------------------------------------------------------------------------
+# __upload_file_and_send_response is a module-level double-underscore function.
+# Module-level dunder-prefix names are NOT name-mangled (only class-level names
+# are mangled), so it appears in pm_module.__dict__ under a CPython-internal
+# obfuscated key. We discover it by scanning for any callable that contains
+# "upload_file" in its name.
+# ---------------------------------------------------------------------------
+
+
+def _find_upload_fn():
+ """Return the module-private __upload_file_and_send_response function."""
+ for name, obj in pm_module.__dict__.items():
+ if "upload_file" in name and callable(obj):
+ return obj
+ return None
+
+
+@pytest.mark.unit
+class TestUploadFileAndSendResponse:
+ @pytest.mark.asyncio
+ async def test_sends_empty_response_when_file_not_found(self):
+ fn = _find_upload_fn()
+ if fn is None:
+ pytest.skip("__upload_file_and_send_response not accessible")
+
+ prompt = _make_message_prompt()
+ mock_socket = AsyncMock()
+
+ with patch("th_cli.test_run.prompt_manager.os.path.isfile", return_value=False):
+ with patch("th_cli.test_run.prompt_manager._send_prompt_response", new_callable=AsyncMock) as mock_send:
+ with patch("th_cli.test_run.prompt_manager.click.echo"):
+ await fn(socket=mock_socket, file_path="/nonexistent.txt", prompt=prompt)
+
+ mock_send.assert_called_once()
+
+ @pytest.mark.asyncio
+ async def test_sends_empty_response_when_file_too_large(self, tmp_path):
+ fn = _find_upload_fn()
+ if fn is None:
+ pytest.skip("__upload_file_and_send_response not accessible")
+
+ big_file = tmp_path / "big.txt"
+ big_file.write_bytes(b"x")
+ prompt = _make_message_prompt()
+ mock_socket = AsyncMock()
+
+ with patch("th_cli.test_run.prompt_manager.os.path.isfile", return_value=True):
+ with patch("th_cli.test_run.prompt_manager.os.path.getsize", return_value=200 * 1024 * 1024):
+ with patch("th_cli.test_run.prompt_manager._send_prompt_response", new_callable=AsyncMock) as mock_send:
+ with patch("th_cli.test_run.prompt_manager.click.echo"):
+ await fn(socket=mock_socket, file_path=str(big_file), prompt=prompt)
+
+ mock_send.assert_called_once()
+
+
+# ---------------------------------------------------------------------------
+# __handle_message_prompt
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.unit
+class TestHandleMessagePrompt:
+ @pytest.mark.asyncio
+ async def test_sends_ack_response(self):
+ prompt = _make_message_prompt()
+ mock_socket = AsyncMock()
+
+ with patch("th_cli.test_run.prompt_manager._send_prompt_response", new_callable=AsyncMock) as mock_send:
+ with patch("th_cli.test_run.prompt_manager.click.echo"):
+ await handle_prompt(
+ socket=mock_socket,
+ request=prompt,
+ message_type=MessageTypeEnum.MESSAGE_REQUEST,
+ )
+
+ mock_send.assert_called_once()
+ call_kwargs = mock_send.call_args[1]
+ assert call_kwargs.get("response") == "ACK"
+
+
+# ---------------------------------------------------------------------------
+# __handle_stream_verification_prompt
+# Accessed via handle_prompt routing with STREAM_VERIFICATION_REQUEST type.
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.unit
+class TestHandleStreamVerificationPrompt:
+ """Cover __handle_stream_verification_prompt body via handle_prompt routing."""
+
+ def _mock_video_handler(self, stream_ready=True, user_answer=1, init_error=None):
+ vh = MagicMock()
+ vh.http_server = MagicMock()
+ vh.http_server.port = 8999
+ vh.set_prompt_data = MagicMock()
+ vh.start_video_capture_and_stream = AsyncMock(return_value=MagicMock())
+ vh.wait_for_stream_ready = AsyncMock(return_value=stream_ready)
+ vh.initialization_error = init_error
+ vh.wait_for_user_response = AsyncMock(return_value=user_answer)
+ vh.stop_video_capture_and_stream = AsyncMock(return_value=None)
+ return vh
+
+ def setup_method(self):
+ pm_module._video_handler_instance = None
+
+ def teardown_method(self):
+ pm_module._video_handler_instance = None
+
+ @pytest.mark.asyncio
+ async def test_sends_response_when_stream_ready_and_user_answers(self):
+ prompt = _make_stream_prompt()
+ mock_socket = AsyncMock()
+ vh = self._mock_video_handler(stream_ready=True, user_answer=1)
+ pm_module._video_handler_instance = vh
+
+ with patch("th_cli.test_run.prompt_manager._send_prompt_response", new_callable=AsyncMock) as mock_send:
+ with patch("th_cli.test_run.prompt_manager._get_local_ip", return_value="10.0.0.1"):
+ with patch("th_cli.test_run.prompt_manager.click.echo"):
+ await handle_prompt(
+ socket=mock_socket,
+ request=prompt,
+ message_type=MessageTypeEnum.STREAM_VERIFICATION_REQUEST,
+ )
+
+ mock_send.assert_called_once()
+
+ @pytest.mark.asyncio
+ async def test_sends_cancelled_when_stream_not_ready(self):
+ prompt = _make_stream_prompt()
+ mock_socket = AsyncMock()
+ vh = self._mock_video_handler(stream_ready=False, init_error="FFmpeg not found")
+ pm_module._video_handler_instance = vh
+
+ with patch("th_cli.test_run.prompt_manager._send_prompt_response", new_callable=AsyncMock) as mock_send:
+ with patch("th_cli.test_run.prompt_manager._get_local_ip", return_value="10.0.0.1"):
+ with patch("th_cli.test_run.prompt_manager.click.echo"):
+ await handle_prompt(
+ socket=mock_socket,
+ request=prompt,
+ message_type=MessageTypeEnum.STREAM_VERIFICATION_REQUEST,
+ )
+
+ mock_send.assert_called_once()
+ call_kwargs = mock_send.call_args[1]
+ from th_cli.test_run.socket_schemas import UserResponseStatusEnum
+ assert call_kwargs.get("status_code") == UserResponseStatusEnum.CANCELLED
+
+ @pytest.mark.asyncio
+ async def test_sends_cancelled_when_stream_not_ready_no_init_error(self):
+ prompt = _make_stream_prompt()
+ mock_socket = AsyncMock()
+ vh = self._mock_video_handler(stream_ready=False, init_error=None)
+ pm_module._video_handler_instance = vh
+
+ with patch("th_cli.test_run.prompt_manager._send_prompt_response", new_callable=AsyncMock) as mock_send:
+ with patch("th_cli.test_run.prompt_manager._get_local_ip", return_value="10.0.0.1"):
+ with patch("th_cli.test_run.prompt_manager.click.echo"):
+ await handle_prompt(
+ socket=mock_socket,
+ request=prompt,
+ message_type=MessageTypeEnum.STREAM_VERIFICATION_REQUEST,
+ )
+
+ mock_send.assert_called_once()
+
+ @pytest.mark.asyncio
+ async def test_no_send_when_user_answer_is_none(self):
+ prompt = _make_stream_prompt()
+ mock_socket = AsyncMock()
+ vh = self._mock_video_handler(stream_ready=True, user_answer=None)
+ pm_module._video_handler_instance = vh
+
+ with patch("th_cli.test_run.prompt_manager._send_prompt_response", new_callable=AsyncMock) as mock_send:
+ with patch("th_cli.test_run.prompt_manager._get_local_ip", return_value="10.0.0.1"):
+ with patch("th_cli.test_run.prompt_manager.click.echo"):
+ await handle_prompt(
+ socket=mock_socket,
+ request=prompt,
+ message_type=MessageTypeEnum.STREAM_VERIFICATION_REQUEST,
+ )
+
+ mock_send.assert_not_called()
+
+ @pytest.mark.asyncio
+ async def test_missing_options_returns_early(self):
+ """Covers line 136 — options missing/empty."""
+ prompt_no_opts = MagicMock(spec=StreamVerificationPromptRequest)
+ prompt_no_opts.options = {}
+ mock_socket = AsyncMock()
+
+ with patch("th_cli.test_run.prompt_manager._send_prompt_response", new_callable=AsyncMock) as mock_send:
+ with patch("th_cli.test_run.prompt_manager.click.echo"):
+ await handle_prompt(
+ socket=mock_socket,
+ request=prompt_no_opts,
+ message_type=MessageTypeEnum.STREAM_VERIFICATION_REQUEST,
+ )
+
+ mock_send.assert_not_called()
diff --git a/tests/test_run/test_websocket_additional.py b/tests/test_run/test_websocket_additional.py
new file mode 100644
index 0000000..9680bfd
--- /dev/null
+++ b/tests/test_run/test_websocket_additional.py
@@ -0,0 +1,471 @@
+#
+# 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.
+#
+"""Additional coverage tests for th_cli/test_run/websocket.py
+uncovered branches: __log_test_run_update, __log_test_case_update
+(browser-peer warning, step-error cleanup), __handle_incoming_socket_message,
+__handle_test_update, __handle_log_record, __display_manual_pairing_code.
+"""
+
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+
+from th_cli.api_lib_autogen.models import (
+ TestCaseExecution,
+ TestCaseMetadata,
+ TestRunExecutionWithChildren,
+ TestStateEnum,
+ TestStepExecution,
+ TestSuiteExecution,
+ TestSuiteMetadata,
+)
+from th_cli.shared_constants import MessageTypeEnum, TestStateEnum as SharedTestStateEnum
+from th_cli.test_run.socket_schemas import (
+ TestCaseUpdate,
+ TestLogRecord,
+ TestRunUpdate,
+ TestStepUpdate,
+ TestSuiteUpdate,
+ TestUpdate,
+ TimeOutNotification,
+ UserResponseStatusEnum,
+)
+from th_cli.test_run.websocket import TestRunSocket
+
+# ---------------------------------------------------------------------------
+# Shared helpers (mirrors test_websocket_socket.py helpers)
+# ---------------------------------------------------------------------------
+
+_METADATA_DEFAULTS = dict(description="d", version="1.0", source_hash="x", mandatory=False, id=1)
+
+
+def _make_step(title="Step", state=TestStateEnum.passed, errors=None, idx=0) -> TestStepExecution:
+ return TestStepExecution(
+ state=state, title=title, execution_index=idx, id=idx + 100,
+ test_case_execution_id=1, errors=errors,
+ )
+
+
+def _make_case(public_id="TC_X_1_1", title="Case", state=TestStateEnum.passed,
+ errors=None, steps=None, idx=0) -> TestCaseExecution:
+ return TestCaseExecution(
+ state=state, public_id=public_id, execution_index=idx, id=idx + 200,
+ test_suite_execution_id=1, test_case_metadata_id=1, errors=errors,
+ test_case_metadata=TestCaseMetadata(public_id=public_id, title=title, **_METADATA_DEFAULTS),
+ test_step_executions=steps or [],
+ )
+
+
+def _make_suite(cases=None, title="Suite", idx=0) -> TestSuiteExecution:
+ return TestSuiteExecution(
+ state=TestStateEnum.passed, public_id="S1", collection_id="c1",
+ execution_index=idx, id=idx + 300, test_run_execution_id=1,
+ test_suite_metadata_id=1, test_case_executions=cases or [],
+ test_suite_metadata=TestSuiteMetadata(public_id="S1", title=title, **_METADATA_DEFAULTS),
+ )
+
+
+def _make_run(suites=None) -> TestRunExecutionWithChildren:
+ return TestRunExecutionWithChildren(
+ title="Run", id=1, state=TestStateEnum.executing,
+ test_suite_executions=suites or [],
+ )
+
+
+def _make_socket(suites=None, project_config=None) -> TestRunSocket:
+ return TestRunSocket(run=_make_run(suites=suites), project_config_dict=project_config)
+
+
+# ---------------------------------------------------------------------------
+# __log_test_run_update
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.unit
+class TestLogTestRunUpdate:
+ @pytest.mark.asyncio
+ async def test_echoes_state_text(self):
+ s = _make_socket()
+ update = TestRunUpdate(state=SharedTestStateEnum.PASSED, test_run_execution_id=1)
+ with patch("th_cli.test_run.websocket.click.echo") as mock_echo:
+ await s._TestRunSocket__log_test_run_update(update)
+ mock_echo.assert_called()
+ output = " ".join(str(a) for call in mock_echo.call_args_list for a in call[0])
+ assert "PASSED" in output.upper() or "passed" in output.lower()
+
+ @pytest.mark.asyncio
+ async def test_displays_chip_server_info_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("th_cli.test_run.websocket.click.echo"):
+ await s._TestRunSocket__log_test_run_update(update)
+
+ mock_display.assert_called_once()
+ assert s._chip_server_info_displayed is True
+
+ @pytest.mark.asyncio
+ async def test_does_not_display_chip_info_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("th_cli.test_run.websocket.click.echo"):
+ await s._TestRunSocket__log_test_run_update(update)
+
+ mock_display.assert_not_called()
+
+
+# ---------------------------------------------------------------------------
+# __display_manual_pairing_code
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.unit
+class TestDisplayManualPairingCode:
+ @pytest.mark.asyncio
+ async def test_skips_when_no_dut_config(self):
+ s = _make_socket(project_config={})
+ # Should return early without calling get_client
+ with patch("th_cli.test_run.websocket.get_client") as mock_get_client:
+ await s._TestRunSocket__display_manual_pairing_code()
+ mock_get_client.assert_not_called()
+
+ @pytest.mark.asyncio
+ async def test_skips_when_discriminator_missing(self):
+ s = _make_socket(project_config={"dut_config": {"setup_code": 12345}})
+ with patch("th_cli.test_run.websocket.get_client") as mock_get_client:
+ await s._TestRunSocket__display_manual_pairing_code()
+ mock_get_client.assert_not_called()
+
+ @pytest.mark.asyncio
+ async def test_skips_when_setup_code_missing(self):
+ s = _make_socket(project_config={"dut_config": {"discriminator": 1234}})
+ with patch("th_cli.test_run.websocket.get_client") as mock_get_client:
+ await s._TestRunSocket__display_manual_pairing_code()
+ mock_get_client.assert_not_called()
+
+ @pytest.mark.asyncio
+ async def test_handles_api_exception_gracefully(self):
+ s = _make_socket(project_config={
+ "dut_config": {"discriminator": 1234, "setup_code": 20202021}
+ })
+ mock_client = MagicMock()
+ mock_client.aclose = AsyncMock()
+
+ with patch("th_cli.test_run.websocket.get_client", return_value=mock_client):
+ with patch("th_cli.test_run.websocket.AsyncApis", side_effect=RuntimeError("boom")):
+ with patch("th_cli.test_run.websocket.logger"):
+ 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}
+ })
+ 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
+ )
+ mock_async_apis = MagicMock()
+ mock_async_apis.test_run_executions_api = mock_api
+
+ 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"):
+ await s._TestRunSocket__display_manual_pairing_code()
+
+ mock_api.get_chip_server_info_api_v1_test_run_executions_chip_server_info_get.assert_called_once()
+ mock_client.aclose.assert_called_once()
+
+
+# ---------------------------------------------------------------------------
+# __log_test_case_update — browser peer warning and step-error cleanup
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.unit
+class TestLogTestCaseUpdateAdditional:
+ def _call(self, socket, update):
+ socket._TestRunSocket__log_test_case_update(update)
+
+ def test_browser_peer_warning_when_errors_contain_indicator(self):
+ case = _make_case(state=TestStateEnum.failed)
+ suite = _make_suite(cases=[case])
+ s = _make_socket(suites=[suite])
+ update = TestCaseUpdate(
+ state="failed",
+ test_case_execution_index=0,
+ test_suite_execution_index=0,
+ errors=["peer not found"],
+ )
+ with patch("th_cli.test_run.websocket.click.echo") as mock_echo:
+ with patch("th_cli.test_run.websocket.logger"):
+ self._call(s, update)
+ output = " ".join(str(a) for call in mock_echo.call_args_list for a in call[0])
+ assert "BROWSER" in output.upper()
+
+ def test_browser_peer_warning_from_step_errors(self):
+ case = _make_case(state=TestStateEnum.failed)
+ suite = _make_suite(cases=[case])
+ s = _make_socket(suites=[suite])
+ # Pre-populate step errors with a browser peer indicator
+ s.test_case_step_errors[(0, 0)] = ["create_browser_peer failed"]
+
+ update = TestCaseUpdate(
+ state="failed",
+ test_case_execution_index=0,
+ test_suite_execution_index=0,
+ )
+ with patch("th_cli.test_run.websocket.click.echo") as mock_echo:
+ with patch("th_cli.test_run.websocket.logger"):
+ self._call(s, update)
+ output = " ".join(str(a) for call in mock_echo.call_args_list for a in call[0])
+ assert "BROWSER" in output.upper()
+
+ def test_cleans_up_step_errors_after_case_update(self):
+ case = _make_case(state=TestStateEnum.failed)
+ suite = _make_suite(cases=[case])
+ s = _make_socket(suites=[suite])
+ s.test_case_step_errors[(0, 0)] = ["some error"]
+
+ update = TestCaseUpdate(
+ state="failed",
+ test_case_execution_index=0,
+ test_suite_execution_index=0,
+ )
+ with patch("th_cli.test_run.websocket.click.echo"):
+ with patch("th_cli.test_run.websocket.logger"):
+ self._call(s, update)
+
+ assert (0, 0) not in s.test_case_step_errors
+
+ def test_no_browser_warning_for_passed_case(self):
+ case = _make_case(state=TestStateEnum.passed)
+ suite = _make_suite(cases=[case])
+ s = _make_socket(suites=[suite])
+
+ update = TestCaseUpdate(
+ state="passed",
+ test_case_execution_index=0,
+ test_suite_execution_index=0,
+ )
+ with patch("th_cli.test_run.websocket.click.echo") as mock_echo:
+ with patch("th_cli.test_run.websocket.logger"):
+ self._call(s, update)
+
+ output = " ".join(str(a) for call in mock_echo.call_args_list for a in call[0])
+ assert "BROWSER" not in output.upper()
+
+
+# ---------------------------------------------------------------------------
+# __handle_log_record
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.unit
+class TestHandleLogRecord:
+ def test_logs_each_record(self):
+ s = _make_socket()
+ records = [
+ TestLogRecord(level="INFO", timestamp=0.0, message="msg1"),
+ TestLogRecord(level="WARNING", timestamp=1.0, message="msg2"),
+ ]
+ with patch("th_cli.test_run.websocket.logger") as mock_logger:
+ s._TestRunSocket__handle_log_record(records)
+ assert mock_logger.log.call_count == 2
+
+ def test_uses_record_level_and_message(self):
+ s = _make_socket()
+ records = [TestLogRecord(level="ERROR", timestamp=0.0, message="boom")]
+ with patch("th_cli.test_run.websocket.logger") as mock_logger:
+ s._TestRunSocket__handle_log_record(records)
+ mock_logger.log.assert_called_once_with("ERROR", "boom")
+
+ def test_empty_records_list(self):
+ s = _make_socket()
+ with patch("th_cli.test_run.websocket.logger") as mock_logger:
+ s._TestRunSocket__handle_log_record([])
+ mock_logger.log.assert_not_called()
+
+
+# ---------------------------------------------------------------------------
+# __handle_incoming_socket_message routing
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.unit
+class TestHandleIncomingSocketMessage:
+ @pytest.mark.asyncio
+ async def test_routes_test_update(self):
+ s = _make_socket()
+ body = TestRunUpdate(state=SharedTestStateEnum.PASSED, test_run_execution_id=1)
+ update = TestUpdate(test_type="test_run", body=body)
+
+ from th_cli.test_run.socket_schemas import SocketMessage
+ from th_cli.shared_constants import MessageTypeEnum
+ msg = MagicMock()
+ msg.payload = update
+ msg.type = MessageTypeEnum.TEST_UPDATE
+
+ mock_socket = AsyncMock()
+
+ with patch.object(
+ s, "_TestRunSocket__handle_test_update", new_callable=AsyncMock
+ ) as mock_handle:
+ await s._TestRunSocket__handle_incoming_socket_message(socket=mock_socket, message=msg)
+
+ mock_handle.assert_called_once_with(socket=mock_socket, update=update)
+
+ @pytest.mark.asyncio
+ async def test_routes_timeout_notification_silently(self):
+ s = _make_socket()
+ msg = MagicMock()
+ msg.payload = TimeOutNotification(message_id=1)
+ msg.type = MessageTypeEnum.TIME_OUT_NOTIFICATION
+
+ mock_socket = AsyncMock()
+ # Should not raise and not echo anything
+ with patch("th_cli.test_run.websocket.click.echo") as mock_echo:
+ await s._TestRunSocket__handle_incoming_socket_message(socket=mock_socket, message=msg)
+ # Only the unknown-type echo could fire; for TimeOutNotification it should NOT
+ for call in mock_echo.call_args_list:
+ assert "Unknown socket message" not in str(call)
+
+ @pytest.mark.asyncio
+ async def test_routes_log_records(self):
+ s = _make_socket()
+ records = [TestLogRecord(level="INFO", timestamp=0.0, message="hi")]
+ msg = MagicMock()
+ msg.payload = records
+ msg.type = MessageTypeEnum.TEST_LOG_RECORDS
+
+ mock_socket = AsyncMock()
+ with patch.object(s, "_TestRunSocket__handle_log_record") as mock_log:
+ await s._TestRunSocket__handle_incoming_socket_message(socket=mock_socket, message=msg)
+
+ mock_log.assert_called_once_with(records)
+
+ @pytest.mark.asyncio
+ async def test_echoes_error_for_unknown_message(self):
+ s = _make_socket()
+ msg = MagicMock()
+ msg.payload = MagicMock() # not TestUpdate, PromptRequest, list, or TimeOut
+ msg.type = "totally_unknown"
+
+ mock_socket = AsyncMock()
+ with patch("th_cli.test_run.websocket.click.echo") as mock_echo:
+ await s._TestRunSocket__handle_incoming_socket_message(socket=mock_socket, message=msg)
+
+ output = " ".join(str(a) for call in mock_echo.call_args_list for a in call[0])
+ assert "Unknown socket message" in output
+
+
+# ---------------------------------------------------------------------------
+# __handle_test_update — all branches
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.unit
+class TestHandleTestUpdate:
+ @pytest.mark.asyncio
+ async def test_routes_step_update(self):
+ step = _make_step()
+ case = _make_case(steps=[step])
+ suite = _make_suite(cases=[case])
+ s = _make_socket(suites=[suite])
+
+ body = TestStepUpdate(
+ state="passed",
+ test_suite_execution_index=0,
+ test_case_execution_index=0,
+ test_step_execution_index=0,
+ )
+ update = TestUpdate(test_type="test_step", body=body)
+
+ with patch.object(s, "_TestRunSocket__log_test_step_update") as mock_fn:
+ await s._TestRunSocket__handle_test_update(socket=AsyncMock(), update=update)
+ mock_fn.assert_called_once_with(body)
+
+ @pytest.mark.asyncio
+ async def test_routes_case_update(self):
+ case = _make_case()
+ suite = _make_suite(cases=[case])
+ s = _make_socket(suites=[suite])
+
+ body = TestCaseUpdate(
+ state="passed",
+ test_suite_execution_index=0,
+ test_case_execution_index=0,
+ )
+ update = TestUpdate(test_type="test_case", body=body)
+
+ with patch.object(s, "_TestRunSocket__log_test_case_update") as mock_fn:
+ await s._TestRunSocket__handle_test_update(socket=AsyncMock(), update=update)
+ mock_fn.assert_called_once_with(body)
+
+ @pytest.mark.asyncio
+ async def test_routes_suite_update(self):
+ suite = _make_suite()
+ s = _make_socket(suites=[suite])
+
+ body = TestSuiteUpdate(state="passed", test_suite_execution_index=0)
+ update = TestUpdate(test_type="test_suite", body=body)
+
+ with patch.object(s, "_TestRunSocket__log_test_suite_update") as mock_fn:
+ await s._TestRunSocket__handle_test_update(socket=AsyncMock(), update=update)
+ mock_fn.assert_called_once_with(body)
+
+ @pytest.mark.asyncio
+ async def test_routes_run_update_and_closes_socket_when_not_executing(self):
+ s = _make_socket()
+ mock_socket = AsyncMock()
+
+ body = TestRunUpdate(state=SharedTestStateEnum.PASSED, test_run_execution_id=1)
+ update = TestUpdate(test_type="test_run", body=body)
+
+ with patch.object(
+ s, "_TestRunSocket__log_test_run_update", new_callable=AsyncMock
+ ):
+ await s._TestRunSocket__handle_test_update(socket=mock_socket, update=update)
+
+ mock_socket.close.assert_called_once()
+
+ @pytest.mark.asyncio
+ async def test_does_not_close_socket_when_still_executing(self):
+ s = _make_socket()
+ mock_socket = AsyncMock()
+
+ body = TestRunUpdate(state=SharedTestStateEnum.EXECUTING, test_run_execution_id=1)
+ update = TestUpdate(test_type="test_run", body=body)
+
+ with patch.object(
+ s, "_TestRunSocket__log_test_run_update", new_callable=AsyncMock
+ ):
+ await s._TestRunSocket__handle_test_update(socket=mock_socket, update=update)
+
+ mock_socket.close.assert_not_called()
diff --git a/tests/test_socket_schemas.py b/tests/test_socket_schemas.py
new file mode 100644
index 0000000..35c939f
--- /dev/null
+++ b/tests/test_socket_schemas.py
@@ -0,0 +1,433 @@
+#
+# 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.
+#
+"""Unit tests for th_cli/test_run/socket_schemas.py."""
+
+import pytest
+from pydantic import ValidationError
+
+from th_cli.test_run.socket_schemas import (
+ ImageVerificationPromptRequest,
+ MessagePromptRequest,
+ OptionsSelectPromptRequest,
+ PromptRequest,
+ PromptResponse,
+ PushAVStreamVerificationRequest,
+ SocketMessage,
+ StreamVerificationPromptRequest,
+ TestCaseUpdate,
+ TestLogRecord,
+ TestRunUpdate,
+ TestStepUpdate,
+ TestSuiteUpdate,
+ TestUpdate,
+ TextInputPromptRequest,
+ TimeOutNotification,
+ TwoWayTalkVerificationRequest,
+ UserResponseStatusEnum,
+)
+# socket_schemas uses shared_constants.TestStateEnum (uppercase str enum: PASSED, FAILED …)
+# and shared_constants.MessageTypeEnum
+from th_cli.shared_constants import MessageTypeEnum, TestStateEnum
+
+
+# ---------------------------------------------------------------------------
+# UserResponseStatusEnum
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.unit
+class TestUserResponseStatusEnum:
+ def test_okay_is_zero(self):
+ assert UserResponseStatusEnum.OKAY == 0
+
+ def test_cancelled_is_minus_one(self):
+ assert UserResponseStatusEnum.CANCELLED == -1
+
+ def test_timeout_is_minus_two(self):
+ assert UserResponseStatusEnum.TIMEOUT == -2
+
+ def test_invalid_is_minus_three(self):
+ assert UserResponseStatusEnum.INVALID == -3
+
+ def test_all_values_are_int(self):
+ for member in UserResponseStatusEnum:
+ assert isinstance(member.value, int)
+
+
+# ---------------------------------------------------------------------------
+# TestRunUpdate
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.unit
+class TestTestRunUpdate:
+ def test_valid_construction(self):
+ obj = TestRunUpdate(state=TestStateEnum.PASSED, test_run_execution_id=1)
+ assert obj.state == TestStateEnum.PASSED
+ assert obj.test_run_execution_id == 1
+
+ def test_optional_errors_none_by_default(self):
+ obj = TestRunUpdate(state=TestStateEnum.FAILED, test_run_execution_id=2)
+ assert obj.errors is None
+
+ def test_with_errors_and_failures(self):
+ obj = TestRunUpdate(
+ state=TestStateEnum.ERROR,
+ test_run_execution_id=3,
+ errors=["err1"],
+ failures=["fail1"],
+ )
+ assert obj.errors == ["err1"]
+ assert obj.failures == ["fail1"]
+
+
+# ---------------------------------------------------------------------------
+# TestSuiteUpdate
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.unit
+class TestTestSuiteUpdate:
+ def test_valid_construction(self):
+ obj = TestSuiteUpdate(state=TestStateEnum.EXECUTING, test_suite_execution_index=0)
+ assert obj.test_suite_execution_index == 0
+
+ def test_missing_required_field_raises(self):
+ with pytest.raises(ValidationError):
+ TestSuiteUpdate(state=TestStateEnum.PASSED)
+
+
+# ---------------------------------------------------------------------------
+# TestCaseUpdate
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.unit
+class TestTestCaseUpdate:
+ def test_valid_construction(self):
+ obj = TestCaseUpdate(
+ state=TestStateEnum.PASSED,
+ test_suite_execution_index=1,
+ test_case_execution_index=2,
+ )
+ assert obj.test_case_execution_index == 2
+ assert obj.test_suite_execution_index == 1
+
+
+# ---------------------------------------------------------------------------
+# TestStepUpdate
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.unit
+class TestTestStepUpdate:
+ def test_valid_construction(self):
+ obj = TestStepUpdate(
+ state=TestStateEnum.PENDING,
+ test_suite_execution_index=0,
+ test_case_execution_index=1,
+ test_step_execution_index=2,
+ )
+ assert obj.test_step_execution_index == 2
+
+ def test_inherits_from_test_case_update(self):
+ obj = TestStepUpdate(
+ state=TestStateEnum.PASSED,
+ test_suite_execution_index=0,
+ test_case_execution_index=0,
+ test_step_execution_index=0,
+ )
+ assert isinstance(obj, TestCaseUpdate)
+
+
+# ---------------------------------------------------------------------------
+# TestUpdate
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.unit
+class TestTestUpdate:
+ def test_with_run_update_body(self):
+ body = TestRunUpdate(state=TestStateEnum.PASSED, test_run_execution_id=1)
+ obj = TestUpdate(test_type="test_run", body=body)
+ assert obj.test_type == "test_run"
+
+ def test_with_suite_update_body(self):
+ body = TestSuiteUpdate(state=TestStateEnum.EXECUTING, test_suite_execution_index=0)
+ obj = TestUpdate(test_type="test_suite", body=body)
+ assert obj.test_type == "test_suite"
+
+ def test_with_step_update_body(self):
+ body = TestStepUpdate(
+ state=TestStateEnum.PASSED,
+ test_suite_execution_index=0,
+ test_case_execution_index=0,
+ test_step_execution_index=1,
+ )
+ obj = TestUpdate(test_type="test_step", body=body)
+ assert isinstance(obj.body, TestStepUpdate)
+
+
+# ---------------------------------------------------------------------------
+# TimeOutNotification
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.unit
+class TestTimeOutNotification:
+ def test_valid_construction(self):
+ obj = TimeOutNotification(message_id=99)
+ assert obj.message_id == 99
+
+ def test_missing_message_id_raises(self):
+ with pytest.raises(ValidationError):
+ TimeOutNotification()
+
+
+# ---------------------------------------------------------------------------
+# TestLogRecord
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.unit
+class TestTestLogRecord:
+ def test_minimal_construction(self):
+ obj = TestLogRecord(level="INFO", timestamp=1234567890.0, message="hello")
+ assert obj.level == "INFO"
+ assert obj.message == "hello"
+
+ def test_optional_index_fields_default_none(self):
+ obj = TestLogRecord(level="WARNING", timestamp="2025-01-01T00:00:00", message="warn")
+ assert obj.test_suite_execution_index is None
+ assert obj.test_case_execution_index is None
+ assert obj.test_step_execution_index is None
+
+ def test_with_all_fields(self):
+ obj = TestLogRecord(
+ level="ERROR",
+ timestamp=0.0,
+ message="err",
+ test_suite_execution_index=1,
+ test_case_execution_index=2,
+ test_step_execution_index=3,
+ )
+ assert obj.test_suite_execution_index == 1
+ assert obj.test_case_execution_index == 2
+ assert obj.test_step_execution_index == 3
+
+ def test_timestamp_can_be_string(self):
+ obj = TestLogRecord(level="INFO", timestamp="2025-06-01T12:00:00", message="msg")
+ assert obj.timestamp == "2025-06-01T12:00:00"
+
+
+# ---------------------------------------------------------------------------
+# PromptRequest
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.unit
+class TestPromptRequest:
+ def test_valid_construction(self):
+ obj = PromptRequest(prompt="Do something", timeout=30, message_id=1)
+ assert obj.prompt == "Do something"
+ assert obj.timeout == 30
+ assert obj.message_id == 1
+
+
+# ---------------------------------------------------------------------------
+# OptionsSelectPromptRequest
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.unit
+class TestOptionsSelectPromptRequest:
+ def test_valid_construction(self):
+ obj = OptionsSelectPromptRequest(
+ prompt="Select one",
+ timeout=60,
+ message_id=2,
+ options={"PASS": 1, "FAIL": 2},
+ )
+ assert obj.options == {"PASS": 1, "FAIL": 2}
+
+
+# ---------------------------------------------------------------------------
+# TextInputPromptRequest
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.unit
+class TestTextInputPromptRequest:
+ def test_optional_fields_default_none(self):
+ obj = TextInputPromptRequest(prompt="Enter text", timeout=30, message_id=3)
+ assert obj.placeholder_text is None
+ assert obj.default_value is None
+ assert obj.regex_pattern is None
+
+ def test_with_optional_fields(self):
+ obj = TextInputPromptRequest(
+ prompt="Enter text",
+ timeout=30,
+ message_id=3,
+ placeholder_text="Type here",
+ default_value="default",
+ regex_pattern=r"\d+",
+ )
+ assert obj.placeholder_text == "Type here"
+ assert obj.default_value == "default"
+ assert obj.regex_pattern == r"\d+"
+
+
+# ---------------------------------------------------------------------------
+# MessagePromptRequest
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.unit
+class TestMessagePromptRequest:
+ def test_valid_construction(self):
+ obj = MessagePromptRequest(prompt="Acknowledge this", timeout=30, message_id=4)
+ assert isinstance(obj, PromptRequest)
+
+
+# ---------------------------------------------------------------------------
+# StreamVerificationPromptRequest
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.unit
+class TestStreamVerificationPromptRequest:
+ def test_is_options_select(self):
+ obj = StreamVerificationPromptRequest(
+ prompt="Verify stream",
+ timeout=30,
+ message_id=5,
+ options={"OK": 1},
+ )
+ assert isinstance(obj, OptionsSelectPromptRequest)
+
+
+# ---------------------------------------------------------------------------
+# ImageVerificationPromptRequest
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.unit
+class TestImageVerificationPromptRequest:
+ def test_includes_image_hex_str(self):
+ obj = ImageVerificationPromptRequest(
+ prompt="Verify image",
+ timeout=30,
+ message_id=6,
+ options={"PASS": 1},
+ image_hex_str="ffd8ffe0",
+ )
+ assert obj.image_hex_str == "ffd8ffe0"
+
+ def test_missing_image_hex_str_raises(self):
+ with pytest.raises(ValidationError):
+ ImageVerificationPromptRequest(
+ prompt="Verify image",
+ timeout=30,
+ message_id=6,
+ options={"PASS": 1},
+ )
+
+
+# ---------------------------------------------------------------------------
+# TwoWayTalkVerificationRequest
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.unit
+class TestTwoWayTalkVerificationRequest:
+ def test_is_options_select(self):
+ obj = TwoWayTalkVerificationRequest(
+ prompt="Verify talk",
+ timeout=30,
+ message_id=7,
+ options={"PASS": 1, "FAIL": 2},
+ )
+ assert isinstance(obj, OptionsSelectPromptRequest)
+
+
+# ---------------------------------------------------------------------------
+# PushAVStreamVerificationRequest
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.unit
+class TestPushAVStreamVerificationRequest:
+ def test_is_options_select(self):
+ obj = PushAVStreamVerificationRequest(
+ prompt="Verify AV",
+ timeout=30,
+ message_id=8,
+ options={"PASS": 1},
+ )
+ assert isinstance(obj, OptionsSelectPromptRequest)
+
+
+# ---------------------------------------------------------------------------
+# PromptResponse
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.unit
+class TestPromptResponse:
+ def test_integer_response(self):
+ obj = PromptResponse(response=1, status_code=UserResponseStatusEnum.OKAY, message_id=1)
+ assert obj.response == 1
+
+ def test_string_response(self):
+ obj = PromptResponse(response="some text", status_code=UserResponseStatusEnum.OKAY, message_id=2)
+ assert obj.response == "some text"
+
+ def test_cancelled_status(self):
+ obj = PromptResponse(response=0, status_code=UserResponseStatusEnum.CANCELLED, message_id=3)
+ assert obj.status_code == UserResponseStatusEnum.CANCELLED
+
+ def test_timeout_status(self):
+ obj = PromptResponse(response=0, status_code=UserResponseStatusEnum.TIMEOUT, message_id=4)
+ assert obj.status_code == UserResponseStatusEnum.TIMEOUT
+
+
+# ---------------------------------------------------------------------------
+# SocketMessage
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.unit
+class TestSocketMessage:
+ def test_with_prompt_response_payload(self):
+ payload = PromptResponse(
+ response=1,
+ status_code=UserResponseStatusEnum.OKAY,
+ message_id=1,
+ )
+ msg = SocketMessage(type=MessageTypeEnum.PROMPT_RESPONSE, payload=payload)
+ assert msg.type == MessageTypeEnum.PROMPT_RESPONSE
+
+ def test_with_test_log_list_payload(self):
+ logs = [TestLogRecord(level="INFO", timestamp=0.0, message="hello")]
+ msg = SocketMessage(type=MessageTypeEnum.TEST_LOG_RECORDS, payload=logs)
+ assert isinstance(msg.payload, list)
+
+ def test_with_test_update_payload(self):
+ body = TestRunUpdate(state=TestStateEnum.PASSED, test_run_execution_id=1)
+ update = TestUpdate(test_type="test_run", body=body)
+ msg = SocketMessage(type=MessageTypeEnum.TEST_UPDATE, payload=update)
+ assert isinstance(msg.payload, TestUpdate)
diff --git a/tests/test_utils_additional.py b/tests/test_utils_additional.py
new file mode 100644
index 0000000..4c5f6b9
--- /dev/null
+++ b/tests/test_utils_additional.py
@@ -0,0 +1,300 @@
+#
+# 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.
+#
+"""Additional tests for uncovered branches in th_cli/utils.py."""
+
+import json
+import subprocess
+from pathlib import Path
+from unittest.mock import MagicMock, patch
+
+import pytest
+
+from th_cli.exceptions import CLIError
+from th_cli.utils import (
+ add_mapped_property,
+ add_unmapped_property,
+ get_cli_sha,
+ get_cli_version,
+ get_versions,
+ load_json_config,
+ merge_configs,
+)
+
+
+# ---------------------------------------------------------------------------
+# add_mapped_property
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.unit
+class TestAddMappedProperty:
+ def test_creates_nested_structure(self):
+ props = {}
+ add_mapped_property(props, "ssid", "MyNetwork", ("network", "wifi"))
+ assert props["network"]["wifi"]["ssid"] == "MyNetwork"
+
+ def test_deep_nested_path(self):
+ props = {}
+ add_mapped_property(props, "channel", "11", ("network", "thread", "dataset"))
+ assert props["network"]["thread"]["dataset"]["channel"] == "11"
+
+ def test_adds_to_existing_section(self):
+ props = {"network": {"wifi": {"ssid": "existing"}}}
+ add_mapped_property(props, "password", "secret", ("network", "wifi"))
+ assert props["network"]["wifi"]["ssid"] == "existing"
+ assert props["network"]["wifi"]["password"] == "secret"
+
+ def test_single_level_path(self):
+ props = {}
+ add_mapped_property(props, "key", "val", ("section",))
+ assert props["section"]["key"] == "val"
+
+ def test_overwrites_existing_key(self):
+ props = {"network": {"wifi": {"ssid": "old"}}}
+ add_mapped_property(props, "ssid", "new", ("network", "wifi"))
+ assert props["network"]["wifi"]["ssid"] == "new"
+
+
+# ---------------------------------------------------------------------------
+# add_unmapped_property
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.unit
+class TestAddUnmappedProperty:
+ def test_adds_to_current_section(self):
+ props = {"mysection": {}}
+ add_unmapped_property(props, "key", "value", "mysection")
+ assert props["mysection"]["key"] == "value"
+
+ def test_adds_to_root_when_no_section(self):
+ props = {}
+ add_unmapped_property(props, "rootkey", "rootval", "")
+ assert props["rootkey"] == "rootval"
+
+ def test_adds_to_root_when_section_is_none(self):
+ props = {}
+ add_unmapped_property(props, "k", "v", None)
+ assert props["k"] == "v"
+
+ def test_overwrites_existing_value_in_section(self):
+ props = {"sec": {"k": "old"}}
+ add_unmapped_property(props, "k", "new", "sec")
+ assert props["sec"]["k"] == "new"
+
+
+# ---------------------------------------------------------------------------
+# load_json_config — additional branches
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.unit
+class TestLoadJsonConfigAdditional:
+ def test_raises_cli_error_on_invalid_json(self, tmp_path):
+ bad = tmp_path / "bad.json"
+ bad.write_text("{not valid json}")
+ with pytest.raises(CLIError) as exc_info:
+ load_json_config(str(bad))
+ assert "Invalid JSON" in exc_info.value.format_message()
+
+ def test_raises_cli_error_when_config_value_not_dict(self, tmp_path):
+ bad = tmp_path / "bad.json"
+ bad.write_text(json.dumps({"config": "string_not_dict"}))
+ with pytest.raises(CLIError):
+ load_json_config(str(bad))
+
+ def test_raises_cli_error_when_root_not_dict(self, tmp_path):
+ bad = tmp_path / "bad.json"
+ bad.write_text(json.dumps([1, 2, 3]))
+ with pytest.raises(CLIError):
+ load_json_config(str(bad))
+
+ def test_raises_cli_error_on_file_not_found(self, tmp_path):
+ with pytest.raises(CLIError):
+ load_json_config(str(tmp_path / "nonexistent.json"))
+
+ def test_raises_cli_error_on_os_error(self, tmp_path):
+ f = tmp_path / "file.json"
+ f.write_text("{}")
+ with patch("builtins.open", side_effect=OSError("permission denied")):
+ with pytest.raises(CLIError) as exc_info:
+ load_json_config(str(f))
+ assert "Failed to read" in exc_info.value.format_message()
+
+
+# ---------------------------------------------------------------------------
+# merge_configs — additional branch (non-dict override)
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.unit
+class TestMergeConfigsAdditional:
+ def test_scalar_overrides_dict(self):
+ base = {"a": {"b": 1}}
+ override = {"a": "scalar"}
+ result = merge_configs(base, override)
+ assert result["a"] == "scalar"
+
+ def test_dict_overrides_scalar(self):
+ base = {"a": "scalar"}
+ override = {"a": {"b": 2}}
+ result = merge_configs(base, override)
+ assert result["a"] == {"b": 2}
+
+ def test_empty_override_returns_copy_of_base(self):
+ base = {"x": 1, "y": {"z": 2}}
+ result = merge_configs(base, {})
+ assert result == base
+ assert result is not base # deep copy
+
+ def test_empty_base_returns_copy_of_override(self):
+ override = {"a": 1}
+ result = merge_configs({}, override)
+ assert result == {"a": 1}
+
+ def test_does_not_mutate_base(self):
+ base = {"a": {"b": 1}}
+ original = json.loads(json.dumps(base))
+ merge_configs(base, {"a": {"c": 2}})
+ assert base == original
+
+
+# ---------------------------------------------------------------------------
+# get_cli_version
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.unit
+class TestGetCliVersion:
+ def test_returns_string(self):
+ result = get_cli_version()
+ assert isinstance(result, str)
+
+ def test_returns_unknown_when_pyproject_missing(self, tmp_path):
+ with patch("th_cli.utils.get_package_root", return_value=tmp_path):
+ with patch("th_cli.utils.find_git_root", return_value=None):
+ result = get_cli_version()
+ assert result == "unknown"
+
+ def test_returns_version_from_pyproject(self, tmp_path):
+ pyproject = tmp_path / "pyproject.toml"
+ pyproject.write_bytes(b'[project]\nversion = "9.9.9"\n')
+ with patch("th_cli.utils.get_package_root", return_value=tmp_path):
+ result = get_cli_version()
+ assert result == "9.9.9"
+
+ def test_falls_back_to_git_root_when_not_in_package_root(self, tmp_path):
+ git_root = tmp_path / "gitroot"
+ git_root.mkdir()
+ pyproject = git_root / "pyproject.toml"
+ pyproject.write_bytes(b'[project]\nversion = "1.2.3"\n')
+ empty_dir = tmp_path / "empty"
+ empty_dir.mkdir()
+ with patch("th_cli.utils.get_package_root", return_value=empty_dir):
+ with patch("th_cli.utils.find_git_root", return_value=git_root):
+ result = get_cli_version()
+ assert result == "1.2.3"
+
+ def test_returns_unknown_on_ioerror(self, tmp_path):
+ with patch("th_cli.utils.get_package_root", return_value=tmp_path):
+ with patch("builtins.open", side_effect=IOError("read error")):
+ # Need pyproject.toml to exist so the code attempts to open it
+ (tmp_path / "pyproject.toml").write_bytes(b"")
+ result = get_cli_version()
+ assert result == "unknown"
+
+
+# ---------------------------------------------------------------------------
+# get_cli_sha
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.unit
+class TestGetCliSha:
+ def test_returns_string(self):
+ result = get_cli_sha()
+ assert isinstance(result, str)
+
+ def test_returns_unknown_when_no_git_root(self):
+ with patch("th_cli.utils.find_git_root", return_value=None):
+ result = get_cli_sha()
+ assert result == "unknown"
+
+ def test_returns_8char_sha_on_success(self, tmp_path):
+ mock_result = MagicMock()
+ mock_result.stdout = "abcdef1234567890\n"
+ with patch("th_cli.utils.find_git_root", return_value=tmp_path):
+ with patch("th_cli.utils.subprocess.run", return_value=mock_result):
+ result = get_cli_sha()
+ assert result == "abcdef12"
+ assert len(result) == 8
+
+ def test_returns_unknown_on_subprocess_error(self, tmp_path):
+ with patch("th_cli.utils.find_git_root", return_value=tmp_path):
+ with patch(
+ "th_cli.utils.subprocess.run",
+ side_effect=subprocess.CalledProcessError(1, "git"),
+ ):
+ result = get_cli_sha()
+ assert result == "unknown"
+
+ def test_returns_unknown_when_git_not_found(self, tmp_path):
+ with patch("th_cli.utils.find_git_root", return_value=tmp_path):
+ with patch("th_cli.utils.subprocess.run", side_effect=FileNotFoundError("git not found")):
+ result = get_cli_sha()
+ assert result == "unknown"
+
+
+# ---------------------------------------------------------------------------
+# get_versions
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.unit
+class TestGetVersions:
+ def test_returns_dict_on_success(self):
+ mock_client = MagicMock()
+ mock_client.close = MagicMock()
+ mock_version_api = MagicMock()
+ mock_versions = MagicMock()
+ mock_versions.model_dump.return_value = {"backend": "1.0.0"}
+ mock_version_api.get_test_harness_backend_version_api_v1_version_get.return_value = mock_versions
+
+ with patch("th_cli.utils.get_client", return_value=mock_client):
+ with patch("th_cli.utils.SyncApis") as mock_sync_apis_cls:
+ mock_sync_apis = MagicMock()
+ mock_sync_apis.version_api = mock_version_api
+ mock_sync_apis_cls.return_value = mock_sync_apis
+ result = get_versions()
+
+ assert result == {"backend": "1.0.0"}
+ mock_client.close.assert_called_once()
+
+ def test_re_raises_cli_error(self):
+ with patch("th_cli.utils.get_client", side_effect=CLIError("no server")):
+ with pytest.raises(CLIError):
+ get_versions()
+
+ def test_closes_client_on_exception(self):
+ mock_client = MagicMock()
+ mock_client.close = MagicMock()
+
+ with patch("th_cli.utils.get_client", return_value=mock_client):
+ with patch("th_cli.utils.SyncApis", side_effect=RuntimeError("boom")):
+ with pytest.raises(RuntimeError):
+ get_versions()
+
+ mock_client.close.assert_called_once()