From 682b1085982c8b2eaf9c97dbdd18801a339aeafa Mon Sep 17 00:00:00 2001 From: aamj Date: Thu, 6 Aug 2026 11:50:05 -0300 Subject: [PATCH 1/5] Max retained logs added for the CLI log viewer. Also, the websocket closure was postponed for when inactive and now yields to the event loop every 200 records intead of whole batch --- th_cli/test_run/log_viewer.html | 18 +++++++++ th_cli/test_run/websocket.py | 66 ++++++++++++++++++++++++++------- 2 files changed, 70 insertions(+), 14 deletions(-) diff --git a/th_cli/test_run/log_viewer.html b/th_cli/test_run/log_viewer.html index 9d8a88e..9eed8ca 100644 --- a/th_cli/test_run/log_viewer.html +++ b/th_cli/test_run/log_viewer.html @@ -548,6 +548,12 @@ const BATCH_INTERVAL_MS = 50; const MAX_BATCH_SIZE = 50; + // Cap how many entries are kept in memory/rendered in the DOM. A run + // can produce hundreds of thousands of log lines; retaining all of + // them here (unbounded array + DOM nodes) is what freezes the tab. + // The "Download Logs" button reads the full file from disk directly, + // so it isn't affected by this cap. + const MAX_RETAINED_LOGS = 5000; // ── /api/status polling ────────────────────────────────────────── // Used after a clean stream end so we never auto-connect a new SSE session @@ -981,6 +987,18 @@ }}); container.appendChild(fragment); + + // Trim oldest entries once past the retention cap, so memory and + // DOM size stay bounded regardless of total run volume. + if (allLogs.length > MAX_RETAINED_LOGS) {{ + const excess = allLogs.length - MAX_RETAINED_LOGS; + allLogs.splice(0, excess); + for (let i = 0; i < excess; i++) {{ + const oldest = container.firstElementChild; + if (oldest) container.removeChild(oldest); + }} + }} + document.getElementById('logCount').textContent = logCount; if (autoScroll) container.scrollTop = container.scrollHeight; diff --git a/th_cli/test_run/websocket.py b/th_cli/test_run/websocket.py index a368ce6..41262a6 100644 --- a/th_cli/test_run/websocket.py +++ b/th_cli/test_run/websocket.py @@ -13,6 +13,8 @@ # See the License for the specific language governing permissions and # limitations under the License. # +import asyncio + import click import websockets from loguru import logger @@ -57,6 +59,19 @@ WEBSOCKET_MAX_MESSAGE_SIZE = 32 * 1024 * 1024 # 32MB +# After the test run reaches a terminal state, the backend may still have a +# trailing batch of log records queued/in-flight (it flushes and broadcasts +# any pending log entries *after* sending the terminal state update - see +# TestLogHandler.finish()/TestUIObserver.complete_tasks() on the backend). +# Keep draining for a short grace period instead of closing immediately, so +# that trailing batch isn't dropped by a socket we already hung up on. +DRAIN_TIMEOUT_S = 5.0 + +# Yield to the event loop every N log records while processing one batch, so +# a very large batch doesn't block the websocket read loop for its entire +# duration. +LOG_RECORD_YIELD_INTERVAL = 200 + class TestRunSocket: def __init__( @@ -69,6 +84,7 @@ def __init__( self.project_config_dict = project_config_dict or {} self.two_way_talk_handler = two_way_talk_handler self._chip_server_info_displayed = False + self._run_finished = False # Track test step errors for logging # Key: (suite_index, case_index), Value: list of error strings from all steps self.test_case_step_errors: dict[tuple[int, int], list[str]] = {} @@ -86,9 +102,19 @@ async def connect_websocket(self) -> None: try: while True: try: - message = await socket.recv() + if self._run_finished: + # Drain any trailing messages for a short grace + # period instead of closing the instant the + # terminal state update arrives. + message = await asyncio.wait_for(socket.recv(), timeout=DRAIN_TIMEOUT_S) + else: + message = await socket.recv() except websockets.exceptions.ConnectionClosedOK: break + except asyncio.TimeoutError: + # No more trailing messages arrived during the + # drain grace period - safe to close now. + break # skip messages that are bytes, as we're expecting a string.\ if not isinstance(message, str): @@ -104,7 +130,13 @@ async def connect_websocket(self) -> None: click.echo(colorize_error(f"Received invalid socket message: {message}"), err=True) click.echo(colorize_error(e.json()), err=True) finally: - pass # Cleanup if needed + if self._run_finished: + try: + await socket.close() + except websockets.exceptions.ConnectionClosedError: + # Backend closed connection without completing handshake + # This is acceptable as test run completed successfully + pass except websockets.exceptions.ConnectionClosed: # Handle case where backend doesn't complete close handshake properly # This can happen with long-running test executions @@ -113,7 +145,7 @@ async def connect_websocket(self) -> None: async def __handle_incoming_socket_message(self, socket: WebSocketClientProtocol, message: SocketMessage) -> None: if isinstance(message.payload, TestUpdate): - await self.__handle_test_update(socket=socket, update=message.payload) + await self.__handle_test_update(update=message.payload) elif isinstance(message.payload, PromptRequest): # Debug: log the message type logger.debug(f"Received prompt with type: {message.type}") @@ -130,7 +162,7 @@ async def __handle_incoming_socket_message(self, socket: WebSocketClientProtocol two_way_talk_handler=self.two_way_talk_handler, ) elif message.type == MessageTypeEnum.TEST_LOG_RECORDS and isinstance(message.payload, list): - self.__handle_log_record(message.payload) + await self.__handle_log_record(message.payload) elif isinstance(message.payload, TimeOutNotification): # ignore time_out_notification as we handle timeout our selves pass @@ -140,7 +172,7 @@ async def __handle_incoming_socket_message(self, socket: WebSocketClientProtocol err=True, ) - async def __handle_test_update(self, socket: WebSocketClientProtocol, update: TestUpdate) -> None: + async def __handle_test_update(self, update: TestUpdate) -> None: if isinstance(update.body, TestStepUpdate): self.__log_test_step_update(update.body) elif isinstance(update.body, TestCaseUpdate): @@ -150,13 +182,12 @@ async def __handle_test_update(self, socket: WebSocketClientProtocol, update: Te elif isinstance(update.body, TestRunUpdate): await self.__log_test_run_update(update.body) if update.body.state != "executing": - # Test run ended disconnect. - try: - await socket.close() - except websockets.exceptions.ConnectionClosedError: - # Backend closed connection without completing handshake - # This is acceptable as test run completed successfully - pass + # Test run ended. Don't close immediately - the backend may + # still be flushing/broadcasting a trailing batch of log + # entries after this message; let the read loop keep + # draining for a short grace period (see DRAIN_TIMEOUT_S) + # before actually closing. + self._run_finished = True async def __log_test_run_update(self, update: TestRunUpdate) -> None: # Display CHIP server info when test run starts executing (SDK container already running) @@ -316,9 +347,16 @@ def __log_test_step_update(self, update: TestStepUpdate) -> None: step_idx=update.test_step_execution_index, ) - def __handle_log_record(self, records: list[TestLogRecord]) -> None: - for record in records: + async def __handle_log_record(self, records: list[TestLogRecord]) -> None: + # Batches can contain tens of thousands of entries after a large test + # case run. Yield periodically instead of logging the whole batch in + # one uninterrupted stretch, so the websocket read loop (and any + # other pending work, e.g. prompt handling) doesn't stall for the + # entire duration of processing one message. + for i, record in enumerate(records): logger.log(record.level, record.message) + if (i + 1) % LOG_RECORD_YIELD_INTERVAL == 0: + await asyncio.sleep(0) def __suite(self, index: int) -> TestSuiteExecution: return self.run.test_suite_executions[index] From 61e0c3f997b906c4278b4fe091498eebf6b839f6 Mon Sep 17 00:00:00 2001 From: aamj Date: Mon, 10 Aug 2026 16:29:53 -0300 Subject: [PATCH 2/5] Cap the rendering to 2000 lines and changed log viewer download logs feature --- tests/test_run/test_log_stream_handler.py | 14 +--- tests/test_run/test_logs_http_server.py | 82 +--------------------- tests/test_run/test_websocket_socket.py | 43 ++++++++---- th_cli/commands/run_tests.py | 1 + th_cli/test_run/log_stream_handler.py | 27 ++++---- th_cli/test_run/log_viewer.html | 84 ++++++++++++++--------- th_cli/test_run/logging.py | 12 +++- th_cli/test_run/logs_http_server.py | 75 +++++++++----------- 8 files changed, 143 insertions(+), 195 deletions(-) diff --git a/tests/test_run/test_log_stream_handler.py b/tests/test_run/test_log_stream_handler.py index 2c93c72..f16acf8 100644 --- a/tests/test_run/test_log_stream_handler.py +++ b/tests/test_run/test_log_stream_handler.py @@ -52,11 +52,6 @@ def test_clients_is_empty_set(self): 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"): - h = LogStreamHandler() - assert h.log_file_path is None - # --------------------------------------------------------------------------- # Helpers @@ -99,16 +94,9 @@ 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") + h.start(test_run_title="run") 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 diff --git a/tests/test_run/test_logs_http_server.py b/tests/test_run/test_logs_http_server.py index 156ecf7..8f29976 100644 --- a/tests/test_run/test_logs_http_server.py +++ b/tests/test_run/test_logs_http_server.py @@ -26,7 +26,6 @@ import pytest from th_cli.test_run.logs_http_server import ( - ENDPOINT_DOWNLOAD_LOGS, ENDPOINT_LOGS_STREAM, ENDPOINT_ROOT, ENDPOINT_STATUS, @@ -98,12 +97,6 @@ def test_stream_endpoint_calls_stream_logs(self): 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"): @@ -111,60 +104,6 @@ def test_unknown_path_sends_404(self): 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() # --------------------------------------------------------------------------- @@ -419,14 +358,13 @@ def test_sets_server_attributes(self): 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" + assert mock_ths.run_id is None def test_propagates_oserror(self): srv = LogsHTTPServer(port=0) @@ -498,24 +436,6 @@ def test_body_contains_run_title_and_start_time(self): assert body["start_time"] == "2025-06-01" -# --------------------------------------------------------------------------- -# download_logs() — generic exception path -# --------------------------------------------------------------------------- - - -@pytest.mark.unit -class TestDownloadLogsExceptions: - def test_handles_generic_exception_gracefully(self, tmp_path): - log_file = tmp_path / "test.log" - log_file.write_bytes(b"data") - h = _make_handler(server_attrs={"log_file_path": str(log_file)}) - h.wfile = MagicMock() - h.wfile.write.side_effect = RuntimeError("unexpected write error") - with patch("th_cli.test_run.logs_http_server.logger") as mock_logger: - h.download_logs() - mock_logger.error.assert_called_once_with("Error serving log file: unexpected write error") - - # --------------------------------------------------------------------------- # _send_sse_event() — generic exception path # --------------------------------------------------------------------------- diff --git a/tests/test_run/test_websocket_socket.py b/tests/test_run/test_websocket_socket.py index daf531c..bc5f965 100644 --- a/tests/test_run/test_websocket_socket.py +++ b/tests/test_run/test_websocket_socket.py @@ -28,7 +28,14 @@ TestSuiteExecution, TestSuiteMetadata, ) -from th_cli.test_run.socket_schemas import TestCaseUpdate, TestRunUpdate, TestStepUpdate, TestSuiteUpdate, TestUpdate +from th_cli.test_run.socket_schemas import ( + TestCaseUpdate, + TestLogRecord, + TestRunUpdate, + TestStepUpdate, + TestSuiteUpdate, + TestUpdate, +) from th_cli.test_run.websocket import TestRunSocket # --------------------------------------------------------------------------- @@ -304,7 +311,7 @@ async def test_step_update_routed_correctly(self): ), ) with patch.object(s, "_TestRunSocket__log_test_step_update") as mock_fn: - await s._TestRunSocket__handle_test_update(socket=AsyncMock(), update=update) + await s._TestRunSocket__handle_test_update(update=update) mock_fn.assert_called_once() @@ -319,7 +326,7 @@ async def test_case_update_routed_correctly(self): body=TestCaseUpdate(state="passed", test_case_execution_index=0, test_suite_execution_index=0), ) with patch.object(s, "_TestRunSocket__log_test_case_update") as mock_fn: - await s._TestRunSocket__handle_test_update(socket=AsyncMock(), update=update) + await s._TestRunSocket__handle_test_update(update=update) mock_fn.assert_called_once() @@ -333,28 +340,40 @@ async def test_suite_update_routed_correctly(self): body=TestSuiteUpdate(state="passed", test_suite_execution_index=0), ) with patch.object(s, "_TestRunSocket__log_test_suite_update") as mock_fn: - await s._TestRunSocket__handle_test_update(socket=AsyncMock(), update=update) + await s._TestRunSocket__handle_test_update(update=update) mock_fn.assert_called_once() @pytest.mark.asyncio - async def test_run_update_executing_does_not_close_socket(self): + async def test_run_update_executing_leaves_run_not_finished(self): s = _make_socket() - mock_socket = AsyncMock() update = TestUpdate(test_type="test_run", body=TestRunUpdate(state="executing", test_run_execution_id=1)) with patch.object(s, "_TestRunSocket__log_test_run_update", new_callable=AsyncMock): - await s._TestRunSocket__handle_test_update(socket=mock_socket, update=update) + await s._TestRunSocket__handle_test_update(update=update) - mock_socket.close.assert_not_called() + assert s._run_finished is False @pytest.mark.asyncio - async def test_run_update_non_executing_closes_socket(self): + async def test_run_update_non_executing_marks_run_finished(self): s = _make_socket() - mock_socket = AsyncMock() update = TestUpdate(test_type="test_run", body=TestRunUpdate(state="passed", test_run_execution_id=1)) with patch.object(s, "_TestRunSocket__log_test_run_update", new_callable=AsyncMock): - await s._TestRunSocket__handle_test_update(socket=mock_socket, update=update) + await s._TestRunSocket__handle_test_update(update=update) + + assert s._run_finished is True + + @pytest.mark.asyncio + async def test_handle_log_record_logs_every_record(self): + s = _make_socket() + records = [ + TestLogRecord(level="INFO", timestamp=0.0, message=f"msg{i}") for i in range(3) + ] + + with patch("th_cli.test_run.websocket.logger") as mock_logger: + await s._TestRunSocket__handle_log_record(records) - mock_socket.close.assert_called_once() + assert mock_logger.log.call_count == 3 + for record in records: + mock_logger.log.assert_any_call(record.level, record.message) diff --git a/th_cli/commands/run_tests.py b/th_cli/commands/run_tests.py index 3dbb5d8..d4f354b 100644 --- a/th_cli/commands/run_tests.py +++ b/th_cli/commands/run_tests.py @@ -316,6 +316,7 @@ async def run_tests( execution_pics=execution_pics, project_id=project_id, ) + test_logging.set_download_run_id(new_test_run.id) if _contains_webrtc_two_way_talk(selected_tests_dict): _webrtc_handler = TwoWayTalkHandler(port=8999) _webrtc_handler.start_waiting() diff --git a/th_cli/test_run/log_stream_handler.py b/th_cli/test_run/log_stream_handler.py index aace9cb..24ddb7c 100644 --- a/th_cli/test_run/log_stream_handler.py +++ b/th_cli/test_run/log_stream_handler.py @@ -29,7 +29,7 @@ class LogStreamHandler: def __init__(self, port: int = 8998): """Initialize the log stream handler. - + Args: port: Port number for the HTTP server (default: 8998) """ @@ -42,15 +42,13 @@ def __init__(self, port: int = 8998): 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: + def start(self, test_run_title: str = "Test Execution") -> str: """Start the log streaming HTTP server. - + Args: test_run_title: Title of the test run for display - log_file_path: Path to the log file for download functionality - + Returns: URL where logs can be viewed """ @@ -59,12 +57,9 @@ def start(self, test_run_title: str = "Test Execution", log_file_path: Optional[ return self._get_log_viewer_url() try: - # Store log file path for download functionality - self.log_file_path = log_file_path - # Get local IP address local_ip = self._get_local_ip() - + # Start HTTP server self.http_server.start( active_clients=self._clients, @@ -72,8 +67,7 @@ def start(self, test_run_title: str = "Test Execution", log_file_path: Optional[ 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 + tree_lock=self.tree_lock, ) self.is_running = True @@ -87,6 +81,15 @@ def start(self, test_run_title: str = "Test Execution", log_file_path: Optional[ logger.error(f"Failed to start log stream handler: {e}") raise + def set_run_id(self, run_id: int) -> None: + """Tell the HTTP server which run's log to link "Download Logs" to, + once the run has been created (its id isn't known when the server + starts). + """ + if self.is_running: + self.http_server.set_run_id(run_id) + + def stop(self): """Stop the log streaming HTTP server.""" if not self.is_running: diff --git a/th_cli/test_run/log_viewer.html b/th_cli/test_run/log_viewer.html index 9eed8ca..a034bd5 100644 --- a/th_cli/test_run/log_viewer.html +++ b/th_cli/test_run/log_viewer.html @@ -293,6 +293,8 @@ cursor: pointer; transition: all 0.2s; font-weight: 500; + display: inline-block; + text-decoration: none; }} .btn:hover {{ background: #f0f0f0; border-color: #999; }} .btn.active {{ background: #2196F3; border-color: #2196F3; color: white; }} @@ -483,11 +485,13 @@ 🔌 Live logs streaming in real-time from test execution +
- + Download Logs Logs: 0 @@ -518,6 +522,14 @@ diff --git a/th_cli/test_run/logging.py b/th_cli/test_run/logging.py index bf3363b..becc7f5 100644 --- a/th_cli/test_run/logging.py +++ b/th_cli/test_run/logging.py @@ -62,8 +62,7 @@ def configure_logger_for_run(title: str, enable_log_streaming: bool = False) -> from th_cli.test_run.log_stream_handler import LogStreamHandler _log_stream_handler = LogStreamHandler(port=8998) - viewer_url = _log_stream_handler.start(test_run_title=title, log_file_path=log_path) - + viewer_url = _log_stream_handler.start(test_run_title=title) # Add custom sink that forwards logs to the stream handler def stream_sink(message): """Custom sink that forwards logs to the HTTP stream.""" @@ -101,6 +100,15 @@ def stop_log_streaming(): _log_stream_handler = None +def set_download_run_id(run_id: int) -> None: + """Tell the log viewer which run's log to link the "Download Logs" button + to, once the run has been created and its id is known (the log-streaming + server starts before the run exists, so this can't be known up front). + """ + if _log_stream_handler: + _log_stream_handler.set_run_id(run_id) + + def get_log_stream_url() -> Optional[str]: """Get the URL for the log viewer if streaming is enabled. diff --git a/th_cli/test_run/logs_http_server.py b/th_cli/test_run/logs_http_server.py index 23ab7f2..ac3e6ce 100644 --- a/th_cli/test_run/logs_http_server.py +++ b/th_cli/test_run/logs_http_server.py @@ -26,10 +26,11 @@ from loguru import logger +from th_cli.config import config + # HTTP Endpoints ENDPOINT_ROOT = "/" ENDPOINT_LOGS_STREAM = "/api/logs/stream" -ENDPOINT_DOWNLOAD_LOGS = "/download_logs" ENDPOINT_STATUS = "/api/status" @@ -42,8 +43,6 @@ def do_GET(self): self.serve_log_viewer() elif self.path == ENDPOINT_LOGS_STREAM: self.stream_logs() - elif self.path == ENDPOINT_DOWNLOAD_LOGS: - self.download_logs() elif self.path == ENDPOINT_STATUS: self.serve_status() else: @@ -65,41 +64,6 @@ def serve_status(self): self.end_headers() self.wfile.write(body) - def download_logs(self): - """Serve the log file for download using chunked streaming.""" - log_file_path = getattr(self.server, "log_file_path", None) - - if not log_file_path or not Path(log_file_path).exists(): - self.send_error(404, "Log file not found") - return - - try: - file_path = Path(log_file_path) - filename = file_path.name - file_size = file_path.stat().st_size - - self.send_response(200) - self.send_header("Content-Type", "text/plain; charset=utf-8") - self.send_header("Content-Disposition", f'attachment; filename="{filename}"') - self.send_header("Content-Length", str(file_size)) - self.send_header("Access-Control-Allow-Origin", "*") - self.end_headers() - - CHUNK_SIZE = 65536 # 64 KB - bytes_sent = 0 - with open(log_file_path, "rb") as f: - while chunk := f.read(CHUNK_SIZE): - self.wfile.write(chunk) - bytes_sent += len(chunk) - - logger.info(f"Log file downloaded: {filename} ({bytes_sent} bytes)") - - except (BrokenPipeError, ConnectionResetError, ConnectionAbortedError): - # Client disconnected during download - normal, not an error - logger.debug("Client disconnected during log file download") - except Exception as e: - logger.error(f"Error serving log file: {e}") - def stream_logs(self): """Stream logs (and tree events) using Server-Sent Events (SSE).""" logger.info("Client connected for log stream") @@ -222,14 +186,34 @@ def serve_log_viewer(self): """Serve the log viewer HTML page.""" # Get configuration from server test_run_title = getattr(self.server, "test_run_title", "Test Execution") - + run_id = getattr(self.server, "run_id", None) + + # config.hostname is only meaningful to the download link when it's a + # real, routable address. This page can be opened from a different + # device than the one running the CLI (that's why this server binds + # to the LAN IP in the first place) - if config.hostname is just + # "localhost" (the common case when the CLI and backend run on the + # same machine as each other), embedding it here would tell a remote + # browser to download from *itself*. Leave it unset in that case so + # the page falls back to whatever host the browser actually used to + # reach it (correct whenever the CLI and backend share a machine, + # which is the common case); keep it when it's a real configured + # address (correct when the CLI talks to a genuinely separate + # backend host). + backend_host = None if config.hostname in ("localhost", "127.0.0.1") else config.hostname + # Read HTML template from file try: template_path = Path(__file__).parent / "log_viewer.html" with open(template_path, "r", encoding="utf-8") as f: html_template = f.read() - html_content = html_template.format(test_run_title=html.escape(test_run_title)) + # Replace placeholders + html_content = html_template.format( + test_run_title=html.escape(test_run_title), + run_id=json.dumps(run_id), + backend_host=json.dumps(backend_host), + ) except Exception as e: logger.error(f"Failed to load HTML template: {e}") html_content = f""" @@ -271,7 +255,6 @@ def start( tree_state: dict, test_run_title: str = "Test Execution", local_ip: Optional[str] = None, - log_file_path: Optional[str] = None, tree_lock: Optional[threading.Lock] = None, ): """Start the HTTP server for log streaming. @@ -283,7 +266,6 @@ def start( so clients connecting after init_tree() receive a current snapshot. test_run_title: Title shown in the browser UI. local_ip: LAN IP used for display purposes. - log_file_path: Path to the on-disk log file for download. tree_lock: Lock protecting tree_state reads/writes. """ try: @@ -296,8 +278,8 @@ def start( self.server.tree_lock = tree_lock self.server.test_run_title = test_run_title self.server.local_ip = local_ip or "localhost" - self.server.log_file_path = log_file_path self.server.start_time = datetime.datetime.now().isoformat() + self.server.run_id = None logger.info(f"Logs HTTP server configured for test run: {test_run_title}") @@ -316,6 +298,13 @@ def run_server(): logger.error(f"Failed to start logs HTTP server: {e}") raise + def set_run_id(self, run_id: int) -> None: + """Set the run id the "Download Logs" link should point to, once the + run has been created (it doesn't exist yet when the server starts). + """ + if self.server is not None: + self.server.run_id = run_id + def stop(self): """Stop the HTTP server.""" if self.server: From ae33b218c8e2b48db831b1facd41620f282e60dc Mon Sep 17 00:00:00 2001 From: aamj Date: Tue, 11 Aug 2026 14:44:51 -0300 Subject: [PATCH 3/5] Log viewer download button now opens no tab and start the download immediately --- th_cli/test_run/log_viewer.html | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/th_cli/test_run/log_viewer.html b/th_cli/test_run/log_viewer.html index a034bd5..748ce36 100644 --- a/th_cli/test_run/log_viewer.html +++ b/th_cli/test_run/log_viewer.html @@ -875,6 +875,7 @@ pid.textContent = publicId; div.appendChild(pid); }} + }} // Click behaviour if (level === 'step' && si !== null && ci !== null && ki !== null) {{ @@ -1154,8 +1155,10 @@ // comment on BACKEND_HOST above). const host = BACKEND_HOST || window.location.hostname; const btn = document.getElementById('downloadBtn'); - btn.href = `${{window.location.protocol}}//${{host}}/api/v1/test_run_executions/${{RUN_ID}}/log`; - btn.target = '_blank'; + // download=true tells the backend to send Content-Disposition: + // attachment, so the browser downloads the file directly + // instead of just rendering the raw log text in the tab. + btn.href = `${{window.location.protocol}}//${{host}}/api/v1/test_run_executions/${{RUN_ID}}/log?download=true`; btn.removeAttribute('onclick'); btn.title = ''; btn.style.opacity = ''; From efa8a7d37f1d6d05a094d0a76a00ccfe104f7b42 Mon Sep 17 00:00:00 2001 From: aamj Date: Thu, 13 Aug 2026 17:04:32 -0300 Subject: [PATCH 4/5] Pushing the run_id to the queue that feeds the live stream --- th_cli/test_run/log_stream_handler.py | 12 ++++++++++-- th_cli/test_run/log_viewer.html | 27 +++++++++++++++++++++------ th_cli/test_run/logs_http_server.py | 7 +++++++ 3 files changed, 38 insertions(+), 8 deletions(-) diff --git a/th_cli/test_run/log_stream_handler.py b/th_cli/test_run/log_stream_handler.py index 24ddb7c..bff775b 100644 --- a/th_cli/test_run/log_stream_handler.py +++ b/th_cli/test_run/log_stream_handler.py @@ -86,9 +86,17 @@ def set_run_id(self, run_id: int) -> None: once the run has been created (its id isn't known when the server starts). """ - if self.is_running: - self.http_server.set_run_id(run_id) + if not self.is_running: + return + + self.http_server.set_run_id(run_id) + # A viewer may already be connected (the run_id is typically set + # only *after* the viewer URL was printed and likely opened), so + # also push it through the existing SSE stream as a control message + # - a future/refreshed page load will pick it up from the HTTP + # server attribute above, but an already-open one only sees this. + self._broadcast({"type": "run_id", "run_id": run_id}) def stop(self): """Stop the log streaming HTTP server.""" diff --git a/th_cli/test_run/log_viewer.html b/th_cli/test_run/log_viewer.html index 748ce36..65a7a2f 100644 --- a/th_cli/test_run/log_viewer.html +++ b/th_cli/test_run/log_viewer.html @@ -480,6 +480,7 @@ 📋 Real-Time Log Viewer — {test_run_title}
+
🔌 Live logs streaming in real-time from test execution @@ -507,7 +508,7 @@
-
+
📡
Waiting for logs…
@@ -523,11 +524,15 @@