Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 1 addition & 13 deletions tests/test_run/test_log_stream_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
82 changes: 1 addition & 81 deletions tests/test_run/test_logs_http_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -98,73 +97,13 @@ 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"):
h.do_GET()
assert h._error_code == 404


# ---------------------------------------------------------------------------
# download_logs()
# ---------------------------------------------------------------------------


@pytest.mark.unit
class TestDownloadLogs:
def test_404_when_no_log_file_path(self):
h = _make_handler(server_attrs={"log_file_path": None})
with patch("th_cli.test_run.logs_http_server.logger"):
h.download_logs()
assert h._error_code == 404

def test_404_when_file_does_not_exist(self, tmp_path):
h = _make_handler(server_attrs={"log_file_path": str(tmp_path / "missing.log")})
with patch("th_cli.test_run.logs_http_server.logger"):
h.download_logs()
assert h._error_code == 404

def test_200_and_correct_headers_for_existing_file(self, tmp_path):
log_file = tmp_path / "test.log"
log_file.write_bytes(b"log content here")
h = _make_handler(server_attrs={"log_file_path": str(log_file)})

with patch("th_cli.test_run.logs_http_server.logger"):
h.download_logs()

assert h._response_code == 200
assert h._headers_sent.get("Content-Type") == "text/plain; charset=utf-8"
assert "Content-Disposition" in h._headers_sent

def test_file_content_written_to_wfile(self, tmp_path):
content = b"line 1\nline 2\n"
log_file = tmp_path / "run.log"
log_file.write_bytes(content)
h = _make_handler(server_attrs={"log_file_path": str(log_file)})

with patch("th_cli.test_run.logs_http_server.logger"):
h.download_logs()

assert h.wfile.getvalue() == content

def test_handles_broken_pipe_gracefully(self, tmp_path):
log_file = tmp_path / "run.log"
log_file.write_bytes(b"data")
h = _make_handler(server_attrs={"log_file_path": str(log_file)})
# Make wfile.write raise BrokenPipeError
h.wfile = MagicMock()
h.wfile.write.side_effect = BrokenPipeError

with patch("th_cli.test_run.logs_http_server.logger"):
h.download_logs() # must not raise


# ---------------------------------------------------------------------------
# _send_sse_event()
# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
# ---------------------------------------------------------------------------
Expand Down
33 changes: 17 additions & 16 deletions tests/test_run/test_websocket_additional.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()


Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -437,35 +440,33 @@ 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)

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)

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
58 changes: 46 additions & 12 deletions tests/test_run/test_websocket_socket.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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()

Expand All @@ -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()

Expand All @@ -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)
1 change: 1 addition & 0 deletions th_cli/commands/run_tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Loading
Loading