diff --git a/tests/test_run/test_log_stream_handler.py b/tests/test_run/test_log_stream_handler.py index 1f41df8..71093cd 100644 --- a/tests/test_run/test_log_stream_handler.py +++ b/tests/test_run/test_log_stream_handler.py @@ -46,10 +46,11 @@ def test_is_running_initially_false(self): h = LogStreamHandler() assert h.is_running is False - def test_log_queue_is_queue(self): + def test_clients_is_empty_set(self): with patch("th_cli.test_run.log_stream_handler.LogsHTTPServer"): h = LogStreamHandler() - assert isinstance(h.log_queue, queue.Queue) + assert isinstance(h._clients, set) + assert len(h._clients) == 0 def test_log_file_path_initially_none(self): with patch("th_cli.test_run.log_stream_handler.LogsHTTPServer"): @@ -163,24 +164,23 @@ def test_sets_is_running_false(self): assert h.is_running is False - def test_puts_none_sentinel_into_queue(self): + def test_broadcasts_none_sentinel_on_stop(self): h, mock_srv = _make_handler() h.is_running = True + client_q = queue.Queue() + h._clients.add(client_q) with patch("th_cli.test_run.log_stream_handler.logger"): h.stop() - assert h.log_queue.get_nowait() is None + assert client_q.get_nowait() is None - def test_skips_sentinel_when_queue_full(self): + def test_stop_does_not_raise_when_client_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 + client_q = queue.Queue(maxsize=1) + client_q.put_nowait("existing") # fill to capacity + h._clients.add(client_q) with patch("th_cli.test_run.log_stream_handler.logger"): h.stop() # must not raise @@ -196,45 +196,54 @@ 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() + with patch.object(h, "_broadcast") as mock_bcast: + h.add_log_entry(message="ignored") + mock_bcast.assert_not_called() def test_adds_entry_to_queue_when_running(self): h, _ = _make_handler() h.is_running = True + client_q = queue.Queue() + h._clients.add(client_q) h.add_log_entry(message="hello", level="INFO") - entry = h.log_queue.get_nowait() + entry = client_q.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 + client_q = queue.Queue() + h._clients.add(client_q) h.add_log_entry(message="msg") - entry = h.log_queue.get_nowait() + entry = client_q.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 + client_q = queue.Queue() + h._clients.add(client_q) h.add_log_entry(message="msg", timestamp="2025-01-01T00:00:00") - entry = h.log_queue.get_nowait() + entry = client_q.get_nowait() assert entry["timestamp"] == "2025-01-01T00:00:00" def test_level_uppercased(self): h, _ = _make_handler() h.is_running = True + client_q = queue.Queue() + h._clients.add(client_q) h.add_log_entry(message="msg", level="warning") - entry = h.log_queue.get_nowait() + entry = client_q.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"}) + client_q = queue.Queue(maxsize=1) + client_q.put_nowait({"message": "x"}) # fill to capacity + h._clients.add(client_q) h.add_log_entry(message="overflow") # must not raise diff --git a/tests/test_run/test_logs_http_server.py b/tests/test_run/test_logs_http_server.py index 106f310..c5a586f 100644 --- a/tests/test_run/test_logs_http_server.py +++ b/tests/test_run/test_logs_http_server.py @@ -45,6 +45,12 @@ def _make_handler(path="/", server_attrs=None): handler.path = path mock_server = MagicMock() + # Set sensible defaults for the broadcast-pattern attributes so stream_logs() + # doesn't hit MagicMock internals (e.g. deepcopy of threading.Lock). + mock_server.active_clients = set() + mock_server.clients_lock = threading.Lock() + mock_server.tree_state = {} # empty dict avoids deepcopy branch + mock_server.tree_lock = None for attr, value in (server_attrs or {}).items(): setattr(mock_server, attr, value) handler.server = mock_server @@ -64,6 +70,14 @@ def _make_handler(path="/", server_attrs=None): return handler +def _pre_filled_queue(*items): + """Return a queue pre-loaded with items for use when patching queue.Queue.""" + q = queue.Queue() + for item in items: + q.put(item) + return q + + # --------------------------------------------------------------------------- # do_GET routing # --------------------------------------------------------------------------- @@ -202,49 +216,41 @@ def test_data_is_valid_json(self): @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() + h = _make_handler() + with patch("th_cli.test_run.logs_http_server.queue.Queue", return_value=_pre_filled_queue(None)): + 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}) + h = _make_handler() 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() + with patch("th_cli.test_run.logs_http_server.queue.Queue", return_value=_pre_filled_queue(None)): + 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}) + h = _make_handler() 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() + entry = {"message": "hello", "level": "INFO", "timestamp": "2025-01-01"} + with patch("th_cli.test_run.logs_http_server.queue.Queue", return_value=_pre_filled_queue(entry, None)): + 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}) + h = _make_handler() call_count = [0] def _send(ev, data): @@ -254,33 +260,33 @@ def _send(ev, data): return False # disconnect immediately h._send_sse_event = _send + entry1 = {"message": "msg", "level": "INFO", "timestamp": "t"} + entry2 = {"message": "msg2", "level": "INFO", "timestamp": "t"} + with patch("th_cli.test_run.logs_http_server.queue.Queue", return_value=_pre_filled_queue(entry1, entry2)): + with patch("th_cli.test_run.logs_http_server.logger"): + h.stream_logs() - 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}) + def test_no_active_clients_on_server_returns_early(self): + h = _make_handler(server_attrs={"active_clients": None, "clients_lock": 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() + """Covers line `if sent_count % 100 == 0` debug message.""" + entries = [{"message": f"msg{i}", "level": "INFO", "timestamp": "t"} for i in range(100)] + entries.append(None) # sentinel + pre_q = queue.Queue() + for e in entries: + pre_q.put(e) + + h = _make_handler() + with patch("th_cli.test_run.logs_http_server.queue.Queue", return_value=pre_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() @@ -381,7 +387,8 @@ def test_server_thread_initially_none(self): class TestLogsHTTPServerStart: def test_creates_threading_http_server(self): srv = LogsHTTPServer(port=0) - q = queue.Queue() + clients = set() + lock = threading.Lock() with patch("th_cli.test_run.logs_http_server.ThreadingHTTPServer") as mock_cls: mock_ths = MagicMock() @@ -390,23 +397,32 @@ def test_creates_threading_http_server(self): 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") + srv.start(active_clients=clients, clients_lock=lock, tree_state={}, 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() + clients = set() + lock = threading.Lock() 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 + srv.start( + active_clients=clients, + clients_lock=lock, + tree_state={}, + test_run_title="MyTitle", + local_ip="1.2.3.4", + log_file_path="/tmp/f.log", + ) + + assert mock_ths.active_clients is clients + assert mock_ths.clients_lock is lock 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" @@ -416,7 +432,7 @@ def test_propagates_oserror(self): 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") + srv.start(active_clients=set(), clients_lock=threading.Lock(), tree_state={}, test_run_title="run") # --------------------------------------------------------------------------- diff --git a/th_cli/test_run/log_stream_handler.py b/th_cli/test_run/log_stream_handler.py index 0c56aca..aace9cb 100644 --- a/th_cli/test_run/log_stream_handler.py +++ b/th_cli/test_run/log_stream_handler.py @@ -16,7 +16,8 @@ import datetime import queue import socket -from typing import Optional +import threading +from typing import Any, Optional from loguru import logger @@ -34,10 +35,15 @@ def __init__(self, port: int = 8998): """ self.port = port self.http_server = LogsHTTPServer(port=port) - self.log_queue: queue.Queue = queue.Queue(maxsize=1000) + # Per-client broadcast set — each connected SSE client owns its own queue. + self._clients: set = set() + self._clients_lock = threading.Lock() + # Mutable dict kept in-place so the server's reference always reflects current state. + self.tree_state: dict = {} + self.tree_lock = threading.Lock() self.is_running = False self.log_file_path: Optional[str] = None - + def start(self, test_run_title: str = "Test Execution", log_file_path: Optional[str] = None) -> str: """Start the log streaming HTTP server. @@ -51,7 +57,7 @@ def start(self, test_run_title: str = "Test Execution", log_file_path: Optional[ if self.is_running: logger.warning("Log stream handler already running") return self._get_log_viewer_url() - + try: # Store log file path for download functionality self.log_file_path = log_file_path @@ -61,78 +67,197 @@ def start(self, test_run_title: str = "Test Execution", log_file_path: Optional[ # Start HTTP server self.http_server.start( - log_queue=self.log_queue, + active_clients=self._clients, + clients_lock=self._clients_lock, + tree_state=self.tree_state, test_run_title=test_run_title, local_ip=local_ip, log_file_path=log_file_path, + tree_lock=self.tree_lock ) - + self.is_running = True - + viewer_url = f"http://{local_ip}:{self.port}" logger.info(f"Log stream viewer started: {viewer_url}") - + return viewer_url - + except Exception as e: logger.error(f"Failed to start log stream handler: {e}") raise - + def stop(self): """Stop the log streaming HTTP server.""" if not self.is_running: return - + try: - # Signal end of stream - if not self.log_queue.full(): - try: - self.log_queue.put_nowait(None) - except queue.Full: - pass - - # Stop HTTP server + self._broadcast(None) + with self._clients_lock: + self._clients.clear() + self.http_server.stop() - self.is_running = False logger.info("Log stream handler stopped") - + except Exception as e: logger.error(f"Error stopping log stream handler: {e}") - - def add_log_entry( - self, - message: str, - level: str = "INFO", - timestamp: Optional[str] = None - ): - """Add a log entry to the stream. - - Args: - message: Log message text - level: Log level (INFO, WARNING, ERROR, DEBUG, etc.) - timestamp: ISO format timestamp (auto-generated if not provided) - """ + + def add_log_entry(self, message: str, level: str = "INFO", timestamp: Optional[str] = None): + """Add a log entry to the stream.""" if not self.is_running: return - + if timestamp is None: timestamp = datetime.datetime.now().isoformat() - + log_entry = { "message": message, "level": level.upper(), "timestamp": timestamp, } - + + self._broadcast(log_entry) + + # ------------------------------------------------------------------ + # Tree management + # ------------------------------------------------------------------ + + def init_tree(self, run: Any) -> None: + """Build the tree from a TestRunExecutionWithChildren and broadcast it. + + Keeps tree_state mutated in-place so the server's reference stays valid + for clients that connect (or reconnect) after this point. + """ + if not self.is_running: + return + try: - # Try to add to queue without blocking - self.log_queue.put_nowait(log_entry) - except queue.Full: - # Queue is full, skip this entry silently to avoid blocking - # This is acceptable for real-time streaming when no browser is connected + tree = self._build_tree(run) + with self.tree_lock: + self.tree_state.clear() + self.tree_state.update(tree) + + self._broadcast({"type": "tree_init", "data": dict(tree)}) + except Exception as e: + logger.debug(f"Error initialising tree: {e}") + + def update_tree_node( + self, + state: str, + suite_idx: Optional[int] = None, + case_idx: Optional[int] = None, + step_idx: Optional[int] = None, + ) -> None: + """Update a single node's state and queue a tree_update event. + + Pass only the indices that identify the target level: + - suite_idx=None → run-level update + - suite_idx=N, case_idx=None → suite-level update + - suite_idx=N, case_idx=M, step_idx=None → case-level update + - suite_idx=N, case_idx=M, step_idx=K → step-level update + """ + if not self.is_running: + return + + if suite_idx is None: + node_type = "run" + elif case_idx is None: + node_type = "suite" + elif step_idx is None: + node_type = "case" + else: + node_type = "step" + + # Mutate tree_state in-place (kept consistent for reconnecting clients). + try: + with self.tree_lock: + if node_type == "run": + self.tree_state["state"] = state + elif node_type == "suite": + self.tree_state["suites"][suite_idx]["state"] = state + elif node_type == "case": + self.tree_state["suites"][suite_idx]["cases"][case_idx]["state"] = state + else: + self.tree_state["suites"][suite_idx]["cases"][case_idx]["steps"][step_idx]["state"] = state + except (IndexError, KeyError, TypeError): pass - + + event = { + "type": "tree_update", + "node_type": node_type, + "suite_idx": suite_idx, + "case_idx": case_idx, + "step_idx": step_idx, + "state": state, + } + + self._broadcast(event) + + # ------------------------------------------------------------------ + # Internals + # ------------------------------------------------------------------ + + def _broadcast(self, event) -> None: + """Put event into every active client's queue; drops silently if a queue is full.""" + with self._clients_lock: + clients = list(self._clients) + for q in clients: + try: + q.put_nowait(event) + except queue.Full: + pass + + def _build_tree(self, run: Any) -> dict: + """Construct a plain-dict tree from a TestRunExecutionWithChildren.""" + tree: dict = { + "title": getattr(run, "title", "Test Run"), + "state": self._get_state(run), + "suites": [], + } + + for suite_idx, suite in enumerate(getattr(run, "test_suite_executions", []) or []): + smeta = getattr(suite, "test_suite_metadata", None) + suite_node: dict = { + "index": suite_idx, + "title": getattr(smeta, "title", f"Suite {suite_idx}") if smeta else f"Suite {suite_idx}", + "state": self._get_state(suite), + "cases": [], + } + + for case_idx, case in enumerate(getattr(suite, "test_case_executions", []) or []): + cmeta = getattr(case, "test_case_metadata", None) + case_node: dict = { + "index": case_idx, + "title": getattr(cmeta, "title", f"Case {case_idx}") if cmeta else f"Case {case_idx}", + "public_id": getattr(cmeta, "public_id", "") if cmeta else "", + "state": self._get_state(case), + "steps": [], + } + + for step_idx, step in enumerate(getattr(case, "test_step_executions", []) or []): + case_node["steps"].append({ + "index": step_idx, + "title": getattr(step, "title", f"Step {step_idx}"), + "state": self._get_state(step), + }) + + suite_node["cases"].append(case_node) + + tree["suites"].append(suite_node) + + return tree + + def _get_state(self, obj: Any) -> str: + try: + state = getattr(obj, "state", None) + if state is None: + return "pending" + return state.value if hasattr(state, "value") else str(state) + except Exception: + return "pending" + def _get_local_ip(self) -> str: """Get the local IP address of the machine. @@ -149,7 +274,7 @@ def _get_local_ip(self) -> str: return local_ip except Exception: return "localhost" - + def _get_log_viewer_url(self) -> str: """Get the URL for the log viewer. diff --git a/th_cli/test_run/log_viewer.html b/th_cli/test_run/log_viewer.html index 4e38ca6..9d8a88e 100644 --- a/th_cli/test_run/log_viewer.html +++ b/th_cli/test_run/log_viewer.html @@ -4,6 +4,8 @@ Real-Time Log Viewer -
-
-
Matter Certification Tool
+ + +
+
+
Matter Certification Tool
+
+
+
+ + Connecting...
-
-
- - Connecting... -
-
CLI
-
-
Log Viewer
-
Real-Time Stream
+
CLI
+
+
Log Viewer
+
Real-Time Stream
+
+
+
+ + + + + +
+ + +
+
☰ Execution Tree
+
+
+
+
Waiting for
execution to start…
+
-
-