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_additional.py b/tests/test_run/test_websocket_additional.py index 9680bfd..c95a858 100644 --- a/tests/test_run/test_websocket_additional.py +++ b/tests/test_run/test_websocket_additional.py @@ -289,27 +289,30 @@ def test_no_browser_warning_for_passed_case(self): @pytest.mark.unit class TestHandleLogRecord: - def test_logs_each_record(self): + @pytest.mark.asyncio + async 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) + await s._TestRunSocket__handle_log_record(records) assert mock_logger.log.call_count == 2 - def test_uses_record_level_and_message(self): + @pytest.mark.asyncio + async 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) + await s._TestRunSocket__handle_log_record(records) mock_logger.log.assert_called_once_with("ERROR", "boom") - def test_empty_records_list(self): + @pytest.mark.asyncio + async 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([]) + await s._TestRunSocket__handle_log_record([]) mock_logger.log.assert_not_called() @@ -339,7 +342,7 @@ async def test_routes_test_update(self): ) 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) + mock_handle.assert_called_once_with(update=update) @pytest.mark.asyncio async def test_routes_timeout_notification_silently(self): @@ -408,7 +411,7 @@ async def test_routes_step_update(self): 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) + await s._TestRunSocket__handle_test_update(update=update) mock_fn.assert_called_once_with(body) @pytest.mark.asyncio @@ -425,7 +428,7 @@ async def test_routes_case_update(self): 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) + await s._TestRunSocket__handle_test_update(update=update) mock_fn.assert_called_once_with(body) @pytest.mark.asyncio @@ -437,13 +440,12 @@ async def test_routes_suite_update(self): 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) + await s._TestRunSocket__handle_test_update(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) @@ -451,14 +453,13 @@ async def test_routes_run_update_and_closes_socket_when_not_executing(self): 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_called_once() + assert s._run_finished is True @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) @@ -466,6 +467,6 @@ async def test_does_not_close_socket_when_still_executing(self): 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 diff --git a/tests/test_run/test_websocket_socket.py b/tests/test_run/test_websocket_socket.py index daf531c..324b60f 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,55 @@ 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_run_update_pending_leaves_run_not_finished(self): + # Regression test: "pending" is non-terminal (backend's TestRun.completed() + # excludes both PENDING and EXECUTING), so it must not close the socket. + # A prior implementation used a negation check (`state != "executing"`) + # that misclassified any non-"executing" state, including "pending", as + # terminal. + s = _make_socket() + + update = TestUpdate(test_type="test_run", body=TestRunUpdate(state="pending", test_run_execution_id=1)) + with patch.object(s, "_TestRunSocket__log_test_run_update", new_callable=AsyncMock): + await s._TestRunSocket__handle_test_update(update=update) + + assert s._run_finished is False + + @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..bff775b 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,23 @@ 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 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.""" if not self.is_running: diff --git a/th_cli/test_run/log_viewer.html b/th_cli/test_run/log_viewer.html index 9d8a88e..5dd8c06 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; }} @@ -478,16 +480,19 @@ 📋 Real-Time Log Viewer — {test_run_title} +
🔌 Live logs streaming in real-time from test execution
+
- + Download Logs Logs: 0 @@ -503,7 +508,7 @@
-
+
📡
Waiting for logs…
@@ -518,6 +523,18 @@ 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..594108d 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") @@ -183,6 +147,13 @@ def stream_logs(self): if not self._send_sse_event("tree_update", entry): client_disconnected = True + elif entry_type == "run_id": + # Control message, not a log line - lets an already- + # connected viewer pick up the run id once it's known, + # instead of only a fresh page load. + if not self._send_sse_event("run_id", {"run_id": entry.get("run_id")}): + client_disconnected = True + else: # Regular log entry (no "type" key, or type=="log"). if not self._send_sse_event("log", entry): @@ -222,14 +193,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 +262,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 +273,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 +285,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 +305,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: diff --git a/th_cli/test_run/websocket.py b/th_cli/test_run/websocket.py index a368ce6..887c9be 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 @@ -37,7 +39,7 @@ colorize_state, ) from th_cli.config import config -from th_cli.shared_constants import MessageTypeEnum +from th_cli.shared_constants import MessageTypeEnum, TestStateEnum from .logging import get_log_stream_handler from .prompt_manager import handle_file_upload_request, handle_prompt @@ -57,6 +59,25 @@ 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 + +# TestRun states that are not yet finished, mirroring the backend's own +# TestRun.completed() contract (state not in [PENDING, EXECUTING]). Anything +# else is terminal - checked explicitly rather than negating "executing" so +# this can't misclassify a non-terminal state (e.g. PENDING) as terminal. +NON_TERMINAL_RUN_STATES = (TestStateEnum.PENDING, TestStateEnum.EXECUTING) + class TestRunSocket: def __init__( @@ -69,6 +90,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 +108,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 +136,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 +151,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 +168,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 +178,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): @@ -149,14 +187,13 @@ async def __handle_test_update(self, socket: WebSocketClientProtocol, update: Te self.__log_test_suite_update(update.body) 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 + if update.body.state not in NON_TERMINAL_RUN_STATES: + # 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 +353,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]