Skip to content

Commit c04a811

Browse files
committed
Cap the rendering to 2000 lines and changed log viewer download logs feature
1 parent ffba865 commit c04a811

6 files changed

Lines changed: 142 additions & 148 deletions

File tree

tests/test_run/test_websocket_socket.py

Lines changed: 31 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,14 @@
2828
TestSuiteExecution,
2929
TestSuiteMetadata,
3030
)
31-
from th_cli.test_run.socket_schemas import TestCaseUpdate, TestRunUpdate, TestStepUpdate, TestSuiteUpdate, TestUpdate
31+
from th_cli.test_run.socket_schemas import (
32+
TestCaseUpdate,
33+
TestLogRecord,
34+
TestRunUpdate,
35+
TestStepUpdate,
36+
TestSuiteUpdate,
37+
TestUpdate,
38+
)
3239
from th_cli.test_run.websocket import TestRunSocket
3340

3441
# ---------------------------------------------------------------------------
@@ -304,7 +311,7 @@ async def test_step_update_routed_correctly(self):
304311
),
305312
)
306313
with patch.object(s, "_TestRunSocket__log_test_step_update") as mock_fn:
307-
await s._TestRunSocket__handle_test_update(socket=AsyncMock(), update=update)
314+
await s._TestRunSocket__handle_test_update(update=update)
308315

309316
mock_fn.assert_called_once()
310317

@@ -319,7 +326,7 @@ async def test_case_update_routed_correctly(self):
319326
body=TestCaseUpdate(state="passed", test_case_execution_index=0, test_suite_execution_index=0),
320327
)
321328
with patch.object(s, "_TestRunSocket__log_test_case_update") as mock_fn:
322-
await s._TestRunSocket__handle_test_update(socket=AsyncMock(), update=update)
329+
await s._TestRunSocket__handle_test_update(update=update)
323330

324331
mock_fn.assert_called_once()
325332

@@ -333,28 +340,40 @@ async def test_suite_update_routed_correctly(self):
333340
body=TestSuiteUpdate(state="passed", test_suite_execution_index=0),
334341
)
335342
with patch.object(s, "_TestRunSocket__log_test_suite_update") as mock_fn:
336-
await s._TestRunSocket__handle_test_update(socket=AsyncMock(), update=update)
343+
await s._TestRunSocket__handle_test_update(update=update)
337344

338345
mock_fn.assert_called_once()
339346

340347
@pytest.mark.asyncio
341-
async def test_run_update_executing_does_not_close_socket(self):
348+
async def test_run_update_executing_leaves_run_not_finished(self):
342349
s = _make_socket()
343-
mock_socket = AsyncMock()
344350

345351
update = TestUpdate(test_type="test_run", body=TestRunUpdate(state="executing", test_run_execution_id=1))
346352
with patch.object(s, "_TestRunSocket__log_test_run_update", new_callable=AsyncMock):
347-
await s._TestRunSocket__handle_test_update(socket=mock_socket, update=update)
353+
await s._TestRunSocket__handle_test_update(update=update)
348354

349-
mock_socket.close.assert_not_called()
355+
assert s._run_finished is False
350356

351357
@pytest.mark.asyncio
352-
async def test_run_update_non_executing_closes_socket(self):
358+
async def test_run_update_non_executing_marks_run_finished(self):
353359
s = _make_socket()
354-
mock_socket = AsyncMock()
355360

356361
update = TestUpdate(test_type="test_run", body=TestRunUpdate(state="passed", test_run_execution_id=1))
357362
with patch.object(s, "_TestRunSocket__log_test_run_update", new_callable=AsyncMock):
358-
await s._TestRunSocket__handle_test_update(socket=mock_socket, update=update)
363+
await s._TestRunSocket__handle_test_update(update=update)
364+
365+
assert s._run_finished is True
366+
367+
@pytest.mark.asyncio
368+
async def test_handle_log_record_logs_every_record(self):
369+
s = _make_socket()
370+
records = [
371+
TestLogRecord(level="INFO", timestamp=0.0, message=f"msg{i}") for i in range(3)
372+
]
373+
374+
with patch("th_cli.test_run.websocket.logger") as mock_logger:
375+
await s._TestRunSocket__handle_log_record(records)
359376

360-
mock_socket.close.assert_called_once()
377+
assert mock_logger.log.call_count == 3
378+
for record in records:
379+
mock_logger.log.assert_any_call(record.level, record.message)

th_cli/commands/run_tests.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -224,6 +224,7 @@ async def run_tests(
224224
execution_pics=execution_pics,
225225
project_id=project_id,
226226
)
227+
test_logging.set_download_run_id(new_test_run.id)
227228
if _contains_webrtc_two_way_talk(selected_tests_dict):
228229
_webrtc_handler = TwoWayTalkHandler(port=8999)
229230
_webrtc_handler.start_waiting()

th_cli/test_run/log_stream_handler.py

Lines changed: 19 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -28,55 +28,57 @@ class LogStreamHandler:
2828

2929
def __init__(self, port: int = 8998):
3030
"""Initialize the log stream handler.
31-
31+
3232
Args:
3333
port: Port number for the HTTP server (default: 8998)
3434
"""
3535
self.port = port
3636
self.http_server = LogsHTTPServer(port=port)
3737
self.log_queue: queue.Queue = queue.Queue(maxsize=1000)
3838
self.is_running = False
39-
self.log_file_path: Optional[str] = None
40-
41-
def start(self, test_run_title: str = "Test Execution", log_file_path: Optional[str] = None) -> str:
39+
40+
def start(self, test_run_title: str = "Test Execution") -> str:
4241
"""Start the log streaming HTTP server.
43-
42+
4443
Args:
4544
test_run_title: Title of the test run for display
46-
log_file_path: Path to the log file for download functionality
47-
45+
4846
Returns:
4947
URL where logs can be viewed
5048
"""
5149
if self.is_running:
5250
logger.warning("Log stream handler already running")
5351
return self._get_log_viewer_url()
54-
52+
5553
try:
56-
# Store log file path for download functionality
57-
self.log_file_path = log_file_path
58-
5954
# Get local IP address
6055
local_ip = self._get_local_ip()
61-
56+
6257
# Start HTTP server
6358
self.http_server.start(
6459
log_queue=self.log_queue,
6560
test_run_title=test_run_title,
6661
local_ip=local_ip,
67-
log_file_path=log_file_path,
6862
)
69-
63+
7064
self.is_running = True
71-
65+
7266
viewer_url = f"http://{local_ip}:{self.port}"
7367
logger.info(f"Log stream viewer started: {viewer_url}")
74-
68+
7569
return viewer_url
76-
70+
7771
except Exception as e:
7872
logger.error(f"Failed to start log stream handler: {e}")
7973
raise
74+
75+
def set_run_id(self, run_id: int) -> None:
76+
"""Tell the HTTP server which run's log to link "Download Logs" to,
77+
once the run has been created (its id isn't known when the server
78+
starts).
79+
"""
80+
if self.is_running:
81+
self.http_server.set_run_id(run_id)
8082

8183
def stop(self):
8284
"""Stop the log streaming HTTP server."""

th_cli/test_run/log_viewer.html

Lines changed: 50 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,8 @@
184184
cursor: pointer;
185185
transition: all 0.2s;
186186
font-weight: 500;
187+
display: inline-block;
188+
text-decoration: none;
187189
}}
188190

189191
.btn:hover {{
@@ -496,7 +498,7 @@
496498
<div class="control-group">
497499
<button id="clearBtn" class="btn" onclick="clearLogs()">Clear</button>
498500
<button id="autoScrollBtn" class="btn active" onclick="toggleAutoScroll()">Auto-scroll</button>
499-
<button id="downloadBtn" class="btn download" onclick="downloadLogs()">Download Logs</button>
501+
<a id="downloadBtn" class="btn download" href="#" onclick="return false;" title="Available once the run starts" style="opacity:0.5;cursor:default;">Download Logs</a>
500502
<input type="text" id="searchBox" class="search-box" placeholder="Search logs..." oninput="filterLogs()">
501503
<span class="log-count">Logs: <span id="logCount">0</span></span>
502504
</div>
@@ -522,6 +524,14 @@
522524
</div>
523525

524526
<script>
527+
// Injected server-side: the run's id (or null if the run hasn't been
528+
// created yet) and the backend's configured hostname (or null if it
529+
// was just "localhost"/"127.0.0.1" - meaningless once embedded in a
530+
// page that may be opened from a different device than the one
531+
// running the CLI). See updateDownloadLink() below.
532+
const RUN_ID = {run_id};
533+
const BACKEND_HOST = {backend_host};
534+
525535
let eventSource = null;
526536
let autoScroll = true;
527537
let logCount = 0;
@@ -538,29 +548,43 @@
538548
// The "Download Logs" button reads the full file from disk directly,
539549
// so it isn't affected by this cap.
540550
const MAX_RETAINED_LOGS = 5000;
551+
// Cap how many entries can sit unrendered in the queue. Without this,
552+
// a fast burst (hundreds of thousands of lines) makes the queue grow
553+
// into a multi-minute backlog: the view still eventually renders
554+
// everything, but always shows stale content instead of what's
555+
// happening right now - as bad as no live view at all for reading
556+
// context to answer a prompt while a test is running. Dropping the
557+
// oldest not-yet-rendered entries keeps the view within a few
558+
// seconds of real-time, at the cost of some historical entries never
559+
// appearing live during a burst (the full history is unaffected -
560+
// it's still in the saved log file and via "Download Logs").
561+
const MAX_PENDING_QUEUE = 2000;
541562

542563
function connectToLogStream() {{
543564
const statusIndicator = document.getElementById('statusIndicator');
544565
const statusText = document.getElementById('statusText');
545-
566+
546567
eventSource = new EventSource('/api/logs/stream');
547-
568+
548569
eventSource.addEventListener('connected', function(e) {{
549570
console.log('Connected to log stream');
550571
statusIndicator.className = 'status-indicator connected';
551572
statusText.textContent = 'Connected';
552573
}});
553-
574+
554575
eventSource.addEventListener('log', function(e) {{
555576
const logEntry = JSON.parse(e.data);
556577
logBatchQueue.push(logEntry);
578+
if (logBatchQueue.length > MAX_PENDING_QUEUE) {{
579+
logBatchQueue.splice(0, logBatchQueue.length - MAX_PENDING_QUEUE);
580+
}}
557581
scheduleBatchProcessing();
558582
}});
559-
583+
560584
eventSource.addEventListener('keepalive', function(e) {{
561585
// Silent keepalive
562586
}});
563-
587+
564588
eventSource.addEventListener('end', function(e) {{
565589
console.log('Log stream ended');
566590
statusIndicator.className = 'status-indicator disconnected';
@@ -746,69 +770,6 @@
746770
}});
747771
}}
748772
749-
function downloadLogs() {{
750-
// First try server download (if server is still running)
751-
fetch('/download_logs')
752-
.then(response => {{
753-
if (response.ok) {{
754-
return response.blob();
755-
}}
756-
throw new Error('Server not available');
757-
}})
758-
.then(blob => {{
759-
// Server download successful
760-
const url = window.URL.createObjectURL(blob);
761-
const a = document.createElement('a');
762-
a.href = url;
763-
a.download = 'test_run_logs.log';
764-
document.body.appendChild(a);
765-
a.click();
766-
window.URL.revokeObjectURL(url);
767-
document.body.removeChild(a);
768-
}})
769-
.catch(error => {{
770-
// Server not available, use client-side download
771-
console.log('Server unavailable, using client-side download');
772-
downloadLogsClientSide();
773-
}});
774-
}}
775-
776-
function downloadLogsClientSide() {{
777-
// Build log content from stored logs
778-
let logContent = '';
779-
allLogs.forEach(entry => {{
780-
const timestamp = entry.timestamp || new Date().toISOString();
781-
const level = entry.level || 'INFO';
782-
const message = entry.message || '';
783-
// Replace literal \n strings with actual newlines, then add entry
784-
const cleanMessage = message.replace(/\\n/g, '\n');
785-
logContent += `${{timestamp}} [${{level}}] ${{cleanMessage}}\n`;
786-
}});
787-
788-
if (logContent === '') {{
789-
alert('No logs to download');
790-
return;
791-
}}
792-
793-
// Create blob and download
794-
const blob = new Blob([logContent], {{ type: 'text/plain;charset=utf-8' }});
795-
const url = window.URL.createObjectURL(blob);
796-
const a = document.createElement('a');
797-
a.href = url;
798-
799-
// Generate filename with current timestamp
800-
const now = new Date();
801-
const dateStr = now.toISOString().slice(0,19).replace(/[T:]/g, '-');
802-
a.download = `test_run_logs_${{dateStr}}.log`;
803-
804-
document.body.appendChild(a);
805-
a.click();
806-
window.URL.revokeObjectURL(url);
807-
document.body.removeChild(a);
808-
809-
console.log(`Downloaded ${{allLogs.length}} log entries client-side`);
810-
}}
811-
812773
function escapeHtml(text) {{
813774
const div = document.createElement('div');
814775
div.textContent = text;
@@ -832,7 +793,27 @@
832793
}}
833794
}});
834795
796+
function updateDownloadLink() {{
797+
if (RUN_ID === null) {{
798+
return;
799+
}}
800+
// Prefer the backend's own configured hostname when it's a real
801+
// address; otherwise fall back to whatever host this page was
802+
// actually loaded from (correct whenever the CLI and backend
803+
// run on the same machine, which is the common case - see the
804+
// comment on BACKEND_HOST above).
805+
const host = BACKEND_HOST || window.location.hostname;
806+
const btn = document.getElementById('downloadBtn');
807+
btn.href = `${{window.location.protocol}}//${{host}}/api/v1/test_run_executions/${{RUN_ID}}/log`;
808+
btn.target = '_blank';
809+
btn.removeAttribute('onclick');
810+
btn.title = '';
811+
btn.style.opacity = '';
812+
btn.style.cursor = '';
813+
}}
814+
835815
window.addEventListener('load', function() {{
816+
updateDownloadLink();
836817
connectToLogStream();
837818
}});
838819

th_cli/test_run/logging.py

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ def configure_logger_for_run(title: str, enable_log_streaming: bool = False) ->
6262
from th_cli.test_run.log_stream_handler import LogStreamHandler
6363

6464
_log_stream_handler = LogStreamHandler(port=8998)
65-
viewer_url = _log_stream_handler.start(test_run_title=title, log_file_path=log_path)
65+
viewer_url = _log_stream_handler.start(test_run_title=title)
6666
# Add custom sink that forwards logs to the stream handler
6767
def stream_sink(message):
6868
"""Custom sink that forwards logs to the HTTP stream."""
@@ -91,7 +91,7 @@ def stream_sink(message):
9191
def stop_log_streaming():
9292
"""Stop the log streaming server if it's running."""
9393
global _log_stream_handler
94-
94+
9595
if _log_stream_handler:
9696
try:
9797
_log_stream_handler.stop()
@@ -102,6 +102,15 @@ def stop_log_streaming():
102102
_log_stream_handler = None
103103

104104

105+
def set_download_run_id(run_id: int) -> None:
106+
"""Tell the log viewer which run's log to link the "Download Logs" button
107+
to, once the run has been created and its id is known (the log-streaming
108+
server starts before the run exists, so this can't be known up front).
109+
"""
110+
if _log_stream_handler:
111+
_log_stream_handler.set_run_id(run_id)
112+
113+
105114
def get_log_stream_url() -> Optional[str]:
106115
"""Get the URL for the log viewer if streaming is enabled.
107116

0 commit comments

Comments
 (0)