Found ${{invalidFiles.length}} non-conforming file(s)${{reasonsHtml}}
`;
}} else {{
nonConformingContent.innerHTML = '
All files conform to Matter Spec.
';
}}
nonConformingSection.style.display = 'block';
}}
+ function html_escape(str) {{
+ const div = document.createElement('div');
+ div.textContent = str;
+ return div.innerHTML;
+ }}
+
let currentDashPlayer = null; // Track current player instance
function playStream(streamUrl) {{
From 0f00720eb897b411263f9a2e4c63722e4d328c8d Mon Sep 17 00:00:00 2001
From: Romulo Quidute Filho <116586593+rquidute@users.noreply.github.com>
Date: Wed, 22 Jul 2026 16:17:57 -0300
Subject: [PATCH 7/8] Don't misreport successful log uploads as errors after
WebSocket drop (#1062) (#105)
* Stop misreporting successful log uploads as errors after WebSocket drop
Uploading a large manual test log can keep the backend's event loop
busy long enough that the WebSocket's ping/pong keepalive times out
and the connection is dropped before the CLI can send the prompt
response confirming the upload. Previously this was caught by the
generic exception handler in __upload_file_and_send_response and
reported as 'Unexpected error uploading file: ...', even though the
upload itself had already completed successfully (issue #1062).
- Wrap only the prompt-response send (not the upload) in a dedicated
try/except for websockets.exceptions.ConnectionClosed, and report
it as a distinct warning that makes clear the file was already
uploaded, instead of letting it fall into the same 'unexpected
error' branch as an actual upload failure.
- Add unit tests covering: successful upload + successful response,
successful upload with the WebSocket closed before the response
can be sent, and an actual upload failure (still reported as an
error, as before).
* Address review: catch any exception, not just ConnectionClosed, after successful upload
Only catching websockets.exceptions.ConnectionClosed left other
post-upload notification failures (e.g. websockets.exceptions.
InvalidState, or a plain OSError from a socket already torn down)
to fall through to the outer except Exception block, which still
misreported them as "Unexpected error uploading file" - the exact
bug this fix targets.
Broaden the inner except to Exception, since by this point the
upload has already succeeded and any exception sending the
confirmation is a notification failure, not an upload failure. The
try block scopes exactly one call (_send_prompt_response), so this
isn't a broad catch-all - it matches the actual invariant at this
point in the function.
Remove the now-unused top-level "import websockets" statement and
add a regression test covering a non-ConnectionClosed exception.
* Strip trailing whitespace/CR from uploaded file path input
__prompt_user_for_file_upload read the raw value from aioconsole.ainput()
and only called .strip() when checking for an empty/skip response, then
passed the *unstripped* value to __valid_file_upload() and returned it
unstripped on success. Some terminals/SSH sessions send a trailing \r
(or other whitespace) with the input line; that stray character made
os.path.isfile() fail even though the file existed exactly where the
user said it did, surfacing as a misleading "Invalid file path or
type" error for an otherwise-correct path.
Strip the input once, immediately after reading it, and use the
stripped value consistently for the emptiness check, validation, and
the returned path.
---
tests/test_run/test_prompt_manager.py | 186 ++++++++++++++++++++++++++
th_cli/test_run/prompt_manager.py | 21 ++-
2 files changed, 204 insertions(+), 3 deletions(-)
diff --git a/tests/test_run/test_prompt_manager.py b/tests/test_run/test_prompt_manager.py
index f860da1..8f923b4 100644
--- a/tests/test_run/test_prompt_manager.py
+++ b/tests/test_run/test_prompt_manager.py
@@ -20,7 +20,9 @@
import tempfile
from unittest.mock import AsyncMock, MagicMock, Mock, patch
+import httpx
import pytest
+import websockets
from th_cli.shared_constants import MessageTypeEnum
from th_cli.test_run import prompt_manager
@@ -375,6 +377,190 @@ async def test_empty_input_skips_upload(self):
mock_send.assert_called_once()
assert mock_send.call_args[1]["response"] == ""
+ @pytest.mark.asyncio
+ async def test_strips_trailing_whitespace_from_input(self):
+ """A stray trailing \\r or space from the input stream (e.g. some
+ terminals/SSH sessions send CRLF) must not cause a valid, existing
+ file path to be rejected as invalid."""
+ with tempfile.NamedTemporaryFile(suffix=".log", delete=False) as f:
+ f.write(b"hello")
+ tmp_path = f.name
+
+ try:
+ with patch(
+ "th_cli.test_run.prompt_manager.__upload_file_and_send_response",
+ new_callable=AsyncMock,
+ ) as mock_upload:
+ with patch("aioconsole.ainput", new_callable=AsyncMock, return_value=f"{tmp_path}\r"):
+ with patch("click.echo"):
+ await prompt_manager.handle_file_upload_request(
+ socket=AsyncMock(),
+ request=MagicMock(prompt="Upload file", timeout=30),
+ )
+
+ mock_upload.assert_called_once()
+ assert mock_upload.call_args[1]["file_path"] == tmp_path
+ finally:
+ os.unlink(tmp_path)
+
+
+# ---------------------------------------------------------------------------
+# __upload_file_and_send_response
+# ---------------------------------------------------------------------------
+# `__upload_file_and_send_response` is a module-level name, so it is NOT
+# name-mangled. It's accessed here via getattr() to avoid writing the literal
+# dunder-prefixed attribute inside a class body, which *would* be mangled.
+
+
+def _get_upload_file_and_send_response():
+ return getattr(prompt_manager, "__upload_file_and_send_response")
+
+
+class _FakeAsyncClient:
+ """Minimal async context-manager stand-in for httpx.AsyncClient."""
+
+ def __init__(self, response=None, post_error=None):
+ self._response = response or MagicMock(status_code=200)
+ self._response.raise_for_status = Mock()
+ self._post_error = post_error
+
+ async def __aenter__(self):
+ return self
+
+ async def __aexit__(self, *args):
+ return False
+
+ async def post(self, *args, **kwargs):
+ if self._post_error:
+ raise self._post_error
+ return self._response
+
+
+@pytest.mark.unit
+class TestUploadFileAndSendResponse:
+ """Uploading the file and sending the prompt response are two separate
+ network operations. A failure sending the prompt response after a
+ successful upload must not be reported as an upload failure
+ (see GitHub issue #1062)."""
+
+ @pytest.mark.asyncio
+ async def test_success_sends_prompt_response(self):
+ upload_file_and_send_response = _get_upload_file_and_send_response()
+
+ with tempfile.NamedTemporaryFile(suffix=".txt", delete=False) as f:
+ f.write(b"hello")
+ tmp_path = f.name
+
+ try:
+ with patch("th_cli.test_run.prompt_manager._send_prompt_response", new_callable=AsyncMock) as mock_send:
+ with patch("httpx.AsyncClient", return_value=_FakeAsyncClient()):
+ with patch("click.echo"):
+ await upload_file_and_send_response(
+ socket=AsyncMock(),
+ file_path=tmp_path,
+ prompt=MagicMock(message_id=1),
+ )
+
+ mock_send.assert_called_once()
+ assert mock_send.call_args[1]["response"] == "SUCCESS"
+ finally:
+ os.unlink(tmp_path)
+
+ @pytest.mark.asyncio
+ async def test_websocket_closed_after_successful_upload_reports_warning_not_error(self):
+ """If the upload succeeds but the websocket is closed before the
+ confirmation can be sent, this must be surfaced as a warning about the
+ lost confirmation - not as an upload error - since the file was
+ already uploaded successfully."""
+ upload_file_and_send_response = _get_upload_file_and_send_response()
+
+ with tempfile.NamedTemporaryFile(suffix=".txt", delete=False) as f:
+ f.write(b"hello")
+ tmp_path = f.name
+
+ try:
+ with patch(
+ "th_cli.test_run.prompt_manager._send_prompt_response",
+ new_callable=AsyncMock,
+ side_effect=websockets.exceptions.ConnectionClosedError(None, None, None),
+ ):
+ with patch("httpx.AsyncClient", return_value=_FakeAsyncClient()):
+ with patch("click.echo") as mock_echo:
+ await upload_file_and_send_response(
+ socket=AsyncMock(),
+ file_path=tmp_path,
+ prompt=MagicMock(message_id=1),
+ )
+
+ echoed = " ".join(str(call.args[0]) for call in mock_echo.call_args_list)
+ assert "uploaded successfully" in echoed
+ assert "Unexpected error uploading file" not in echoed
+ finally:
+ os.unlink(tmp_path)
+
+ @pytest.mark.asyncio
+ async def test_other_exception_after_successful_upload_reports_warning_not_error(self):
+ """Any exception sending the confirmation (not just ConnectionClosed -
+ e.g. websockets.exceptions.InvalidState, or a plain OSError) must also
+ be reported as a post-upload notification warning, not an upload
+ error, since the upload already succeeded by this point."""
+ upload_file_and_send_response = _get_upload_file_and_send_response()
+
+ with tempfile.NamedTemporaryFile(suffix=".txt", delete=False) as f:
+ f.write(b"hello")
+ tmp_path = f.name
+
+ try:
+ with patch(
+ "th_cli.test_run.prompt_manager._send_prompt_response",
+ new_callable=AsyncMock,
+ side_effect=OSError("socket already closed"),
+ ):
+ with patch("httpx.AsyncClient", return_value=_FakeAsyncClient()):
+ with patch("click.echo") as mock_echo:
+ await upload_file_and_send_response(
+ socket=AsyncMock(),
+ file_path=tmp_path,
+ prompt=MagicMock(message_id=1),
+ )
+
+ echoed = " ".join(str(call.args[0]) for call in mock_echo.call_args_list)
+ assert "uploaded successfully" in echoed
+ assert "confirmation could not be sent" in echoed
+ assert "Unexpected error uploading file" not in echoed
+ finally:
+ os.unlink(tmp_path)
+
+ @pytest.mark.asyncio
+ async def test_http_error_during_upload_still_reports_error(self):
+ """A failure during the actual upload (before success) must still be
+ reported as an upload error, and the empty response must be sent."""
+ upload_file_and_send_response = _get_upload_file_and_send_response()
+
+ with tempfile.NamedTemporaryFile(suffix=".txt", delete=False) as f:
+ f.write(b"hello")
+ tmp_path = f.name
+
+ try:
+ with patch("th_cli.test_run.prompt_manager._send_prompt_response", new_callable=AsyncMock) as mock_send:
+ with patch(
+ "httpx.AsyncClient",
+ return_value=_FakeAsyncClient(post_error=httpx.ConnectError("boom")),
+ ):
+ with patch("click.echo") as mock_echo:
+ await upload_file_and_send_response(
+ socket=AsyncMock(),
+ file_path=tmp_path,
+ prompt=MagicMock(message_id=1),
+ )
+
+ mock_send.assert_called_once()
+ assert mock_send.call_args[1]["response"] == ""
+ echoed = " ".join(str(call.args[0]) for call in mock_echo.call_args_list)
+ assert "Network error during file upload" in echoed
+ finally:
+ os.unlink(tmp_path)
+
# ---------------------------------------------------------------------------
# _handle_two_way_talk_prompt (via handle_prompt dispatch)
diff --git a/th_cli/test_run/prompt_manager.py b/th_cli/test_run/prompt_manager.py
index e2150dc..1e28540 100644
--- a/th_cli/test_run/prompt_manager.py
+++ b/th_cli/test_run/prompt_manager.py
@@ -480,10 +480,10 @@ async def __prompt_user_for_file_upload(prompt: PromptRequest) -> str:
click.echo("Enter the path to the file to upload (or press Enter to skip): ")
# Wait for input async
- file_path = await aioconsole.ainput()
+ file_path = (await aioconsole.ainput()).strip()
# If user just pressed Enter, return empty string
- if not file_path.strip():
+ if not file_path:
return ""
# Validate file path and type
@@ -531,7 +531,22 @@ async def __upload_file_and_send_response(
response.raise_for_status()
click.echo("✅ File uploaded successfully")
- await _send_prompt_response(socket=socket, response="SUCCESS", prompt=prompt)
+
+ # The upload itself succeeded at this point (the HTTP response was already
+ # received above). Uploading a large log can keep the backend's event loop
+ # busy long enough that the WebSocket's keepalive ping/pong times out and
+ # the connection is dropped before we get a chance to send the prompt
+ # response. Any failure sending that response is therefore a post-upload
+ # notification failure, not an upload failure - report it separately so
+ # it isn't mistaken for one (see GitHub issue #1062).
+ try:
+ await _send_prompt_response(socket=socket, response="SUCCESS", prompt=prompt)
+ except Exception as e:
+ click.echo(
+ "⚠️ File was uploaded successfully, but the confirmation could not be sent "
+ f"to the backend: {e}",
+ err=True,
+ )
except httpx.RequestError as e:
click.echo(f"❌ Network error during file upload: {str(e)}", err=True)
From bf4969409f08f3a8cd11a326b845666e944bdaa9 Mon Sep 17 00:00:00 2001
From: antonio-amjr <116589331+antonio-amjr@users.noreply.github.com>
Date: Fri, 14 Aug 2026 16:50:32 -0300
Subject: [PATCH 8/8] [Fix] Websocket and Log Viewer (#107)
* 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
* Cap the rendering to 2000 lines and changed log viewer download logs feature
* Log viewer download button now opens no tab and start the download immediately
* Pushing the run_id to the queue that feeds the live stream
* Fixing state verifying to match the backends.
---
tests/test_run/test_websocket_socket.py | 58 +++++++--
th_cli/commands/run_tests.py | 1 +
th_cli/test_run/log_stream_handler.py | 52 +++++---
th_cli/test_run/log_viewer.html | 162 +++++++++++++-----------
th_cli/test_run/logging.py | 13 +-
th_cli/test_run/logs_http_server.py | 94 +++++++-------
th_cli/test_run/websocket.py | 76 ++++++++---
7 files changed, 284 insertions(+), 172 deletions(-)
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 6b7532a..beeac27 100644
--- a/th_cli/commands/run_tests.py
+++ b/th_cli/commands/run_tests.py
@@ -224,6 +224,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 0c56aca..dc1cd67 100644
--- a/th_cli/test_run/log_stream_handler.py
+++ b/th_cli/test_run/log_stream_handler.py
@@ -28,7 +28,7 @@ class LogStreamHandler:
def __init__(self, port: int = 8998):
"""Initialize the log stream handler.
-
+
Args:
port: Port number for the HTTP server (default: 8998)
"""
@@ -36,48 +36,64 @@ def __init__(self, port: int = 8998):
self.http_server = LogsHTTPServer(port=port)
self.log_queue: queue.Queue = queue.Queue(maxsize=1000)
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
"""
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
-
# Get local IP address
local_ip = self._get_local_ip()
-
+
# Start HTTP server
self.http_server.start(
log_queue=self.log_queue,
test_run_title=test_run_title,
local_ip=local_ip,
- log_file_path=log_file_path,
)
-
+
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 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.
+ try:
+ self.log_queue.put_nowait({"__event__": "run_id", "run_id": run_id})
+ except queue.Full:
+ # Best-effort: a future page load/refresh will still pick up
+ # the run id via the HTTP server attribute set above.
+ pass
+
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 4e38ca6..718b45e 100644
--- a/th_cli/test_run/log_viewer.html
+++ b/th_cli/test_run/log_viewer.html
@@ -184,6 +184,8 @@
cursor: pointer;
transition: all 0.2s;
font-weight: 500;
+ display: inline-block;
+ text-decoration: none;
}}
.btn:hover {{
@@ -496,7 +498,7 @@