Skip to content

Commit 7fdad56

Browse files
committed
qml: virtualize debug log output
1 parent 6431c3c commit 7fdad56

7 files changed

Lines changed: 876 additions & 189 deletions

File tree

qml/components/DebugLogOutputView.qml

Lines changed: 315 additions & 140 deletions
Large diffs are not rendered by default.

qml/pages/settings/SettingsDebugLog.qml

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,11 @@ Page {
132132
color: Theme.color.neutral9
133133
placeholderTextColor: Theme.color.neutral5
134134
placeholderText: qsTr("Search...")
135+
// The page is unloaded whenever another Settings section is
136+
// selected, while the C++ model intentionally retains its
137+
// filter. Mirror that retained value on re-entry so the field
138+
// and the rows cannot disagree.
139+
text: debugLogModel.filter
135140
verticalAlignment: TextInput.AlignVCenter
136141
selectByMouse: true
137142
Accessible.name: qsTr("Search debug log")
@@ -201,11 +206,13 @@ Page {
201206
topPadding: 10
202207
accessibleName: qsTr("Debug log entries")
203208
autoScrollToBottom: false
204-
// DebugLogModel renders newest-first at the top, so y > 0 means
205-
// "scrolled away from the newest entries" — which is exactly when
206-
// the "N new entries" pill should be offered.
207-
onScrolled: function(y) {
208-
root.userIsScrolled = (y > 0)
209+
// DebugLogModel renders newest-first at the top, so leaving the
210+
// beginning is exactly when the "N new entries" pill applies.
211+
onScrolled: function() {
212+
// A ListView's content origin is not guaranteed to be zero,
213+
// particularly with variable-height rows and incremental model
214+
// changes. Its boundary state is the authoritative answer.
215+
root.userIsScrolled = !logView.atTop
209216
if (logView.atTop && root.pendingNewLines > 0) {
210217
root.pendingNewLines = 0
211218
}
@@ -220,6 +227,7 @@ Page {
220227
Layout.preferredHeight: 36
221228

222229
TextButton {
230+
objectName: "debugLogLoadMoreButton"
223231
anchors.centerIn: parent
224232
text: qsTr("Load more")
225233
textSize: 13

test/functional/qml_test_debug_log.py

Lines changed: 127 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,11 @@
66
77
Walks through Settings → Debug Log and verifies:
88
1. The viewer page loads with a visible search bar and log list.
9-
2. The log list is non-empty (entries were actually loaded).
9+
2. The initial load is capped while older entries remain available.
1010
3. Typing in the search field filters the list; clearing it restores
1111
the full count.
12+
4. Auto-refresh replaces the capped tail without growing it unboundedly.
13+
5. The list virtualizes offscreen rows and can load older rows on demand.
1214
1315
This test requires the binary to be built with -DENABLE_TEST_AUTOMATION=ON.
1416
"""
@@ -22,7 +24,11 @@
2224
import tempfile
2325

2426
from qml_test_harness import GUI_STARTUP_TIMEOUT, dump_qml_tree, find_gui_binary, setup_datadir
25-
from qml_driver import QmlDriver, QmlDriverError
27+
from qml_driver import QmlDriver
28+
29+
30+
INITIAL_LOAD_LIMIT = 1000
31+
SEEDED_HISTORY_LINES = 1200
2632

2733

2834
def assert_close(actual, expected, label, tolerance=1):
@@ -43,6 +49,19 @@ def __init__(self):
4349
self.process = None
4450
self.driver = None
4551
self.datadir = setup_datadir(self.tmpdir)
52+
self._seed_debug_log_history()
53+
54+
def _seed_debug_log_history(self):
55+
"""Create enough history to exercise the initial cap and Load more."""
56+
network_dir = os.path.join(self.datadir, "regtest")
57+
os.makedirs(network_dir, exist_ok=True)
58+
log_path = os.path.join(network_dir, "debug.log")
59+
with open(log_path, "w", encoding="utf-8") as f:
60+
for i in range(SEEDED_HISTORY_LINES):
61+
f.write(
62+
"2026-01-01T00:00:00Z "
63+
f"test-automation seeded-history marker {i}\n"
64+
)
4665

4766
def start(self):
4867
env = dict(os.environ)
@@ -114,10 +133,15 @@ def test_viewer_visibility(gui):
114133

115134

116135
def test_log_has_entries(gui):
117-
"""Verify that log entries were actually loaded into the list view."""
136+
"""Verify that the first snapshot is capped at 1,000 rows."""
118137
print("\n── test_log_has_entries ──────────────────────────────────────────")
119-
count = gui.get_property("debugLogListView", "count")
120-
assert count > 0, f"Expected log entries to be loaded, got count={count}"
138+
count = gui.wait_for_property(
139+
"debugLogListView", "count", INITIAL_LOAD_LIMIT, timeout_ms=5000
140+
)
141+
assert count == INITIAL_LOAD_LIMIT, (
142+
f"Expected the initial load to be capped at {INITIAL_LOAD_LIMIT}, "
143+
f"got count={count}"
144+
)
121145
print(f" PASSED: debugLogListView.count = {count}")
122146
return count
123147

@@ -130,7 +154,6 @@ def test_search_layout_matches_design(gui):
130154
gui.wait_for_property("debugLogSearchDivider", "height", 1, timeout_ms=3000)
131155
gui.wait_for_property("debugLogListView", "topPadding", 10, timeout_ms=3000)
132156
gui.wait_for_property("debugLogListView_row_0", "visible", True, timeout_ms=3000)
133-
gui.wait_for_property("debugLogListView_row_0", "y", 10, timeout_ms=3000)
134157
gui.wait_for_property("debugLogListView_lineNumber_0", "visible", True, timeout_ms=3000)
135158
gui.wait_for_property("debugLogListView_date_0", "visible", True, timeout_ms=3000)
136159

@@ -147,6 +170,13 @@ def test_search_layout_matches_design(gui):
147170
divider_y = gui.get_property("debugLogSearchDivider", "y")
148171
divider_height = gui.get_property("debugLogSearchDivider", "height")
149172
list_y = gui.get_property("debugLogListView", "y")
173+
first_row_viewport_y = (
174+
gui.get_property("debugLogListView_row_0", "y")
175+
- gui.get_property("debugLogListView", "contentY")
176+
)
177+
effective_line_number_width = gui.get_property(
178+
"debugLogListView", "effectiveLineNumberWidth"
179+
)
150180
page_width = gui.get_property("settingsDebugLog", "width")
151181
content_width = gui.get_property("debugLogContentLayout", "width")
152182
expected_content_width = max(0, min(page_width - 40, 600))
@@ -197,20 +227,24 @@ def test_search_layout_matches_design(gui):
197227
"debug log entry command/content spacing")
198228
assert_close(gui.get_property("debugLogListView", "lineNumberWidth"), 20,
199229
"debug log line number slot width")
200-
assert_close(gui.get_property("debugLogListView", "effectiveLineNumberWidth"), 20,
201-
"debug log effective line number slot width")
230+
assert effective_line_number_width >= 20, (
231+
"Expected the effective line-number slot to honor its 20px minimum"
232+
)
202233
assert_close(gui.get_property("debugLogListView", "fontPixelSize"), 12,
203234
"debug log entry font size")
204235
assert_close(gui.get_property("debugLogListView", "textLineHeight"), 17,
205236
"debug log entry line height")
206-
assert_close(gui.get_property("debugLogListView_row_0", "y"), 10,
207-
"first debug log row y")
237+
assert_close(first_row_viewport_y, 10,
238+
"first debug log row viewport y")
208239
assert_close(gui.get_property("debugLogListView_row_0", "spacing"), 10,
209240
"first debug log row column gap")
210241
assert_close(gui.get_property("debugLogListView_entryContent_0", "spacing"), 2,
211242
"first debug log entry internal gap")
212-
assert_close(gui.get_property("debugLogListView_lineNumber_0", "width"), 20,
213-
"first debug log line number width")
243+
assert_close(
244+
gui.get_property("debugLogListView_lineNumber_0", "width"),
245+
effective_line_number_width,
246+
"first debug log line number width",
247+
)
214248
first_text_object = (
215249
"debugLogListView_message_0"
216250
if first_row_has_command
@@ -236,46 +270,56 @@ def test_refresh_button(gui, original_count):
236270

237271

238272
def test_auto_refresh(gui, datadir, current_count):
239-
"""Appending to debug.log triggers auto-refresh and grows the entry count."""
273+
"""Appending refreshes the capped tail without exceeding its load limit."""
240274
print("\n── test_auto_refresh ─────────────────────────────────────────────")
241275
log_path = os.path.join(datadir, "regtest", "debug.log")
242276
ts = datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
243277
marker_body = "test-automation auto-refresh marker"
244278
marker = f"{ts} {marker_body}"
245279
with open(log_path, "a", encoding="utf-8") as f:
246280
f.write(marker + "\n")
247-
try:
248-
new_count = gui.wait_for_property(
249-
"debugLogListView", "count", lambda c: c > current_count, timeout_ms=5000
250-
)
251-
except QmlDriverError:
252-
gui.click("debugLogRefreshButton")
253-
new_count = gui.wait_for_property(
254-
"debugLogListView", "count", lambda c: c > current_count, timeout_ms=3000
255-
)
256-
assert new_count > current_count, (
257-
f"Expected count to grow beyond {current_count} after appending to debug.log, "
258-
f"got {new_count}"
259-
)
260-
print(f" PASSED: count grew from {current_count} to {new_count} after file append")
261281

282+
# A capped model can insert the new row and remove one old row without
283+
# changing count. Filter for the unique marker to observe the actual data
284+
# update rather than treating row-count growth as the refresh signal.
262285
gui.set_text("debugLogSearchField", marker_body)
263-
gui.wait_for_property("debugLogListView", "count", lambda c: c >= 1, timeout_ms=3000)
286+
gui.wait_for_property("debugLogListView", "count", 1, timeout_ms=5000)
287+
gui.wait_for_property(
288+
"debugLogListView_commandlessMessage_0",
289+
"text",
290+
marker_body,
291+
timeout_ms=3000,
292+
)
293+
264294
gui.wait_for_property("debugLogListView_command_0", "visible", False, timeout_ms=3000)
265295
gui.wait_for_property("debugLogListView_commandlessMessage_0", "visible", True, timeout_ms=3000)
266-
gui.wait_for_property("debugLogListView_commandlessMessage_0", "text", marker_body, timeout_ms=3000)
267296
gui.wait_for_property("debugLogListView_message_0", "visible", False, timeout_ms=3000)
297+
gui.invoke("debugLogListView_commandlessMessage_0", "selectAll")
298+
gui.wait_for_property(
299+
"debugLogListView_commandlessMessage_0",
300+
"selectedText",
301+
marker_body,
302+
timeout_ms=3000,
303+
)
268304
print(" PASSED: commandless log entry renders inline with the date")
269305

270306
gui.set_text("debugLogSearchField", "")
271307
restored_count = gui.wait_for_property(
272-
"debugLogListView", "count", lambda c: c >= new_count, timeout_ms=3000
308+
"debugLogListView", "count", current_count, timeout_ms=3000
309+
)
310+
assert restored_count == INITIAL_LOAD_LIMIT, (
311+
f"Expected auto-refresh to retain the {INITIAL_LOAD_LIMIT}-row cap, "
312+
f"got {restored_count}"
313+
)
314+
print(
315+
f" PASSED: appended entry loaded and count remained capped at "
316+
f"{restored_count}"
273317
)
274318
return restored_count
275319

276320

277321
def test_four_digit_line_numbers_are_not_clipped(gui, datadir, current_count):
278-
"""Verify the line-number column expands once visible numbers reach four digits."""
322+
"""Verify virtualized offscreen rows and the four-digit number column."""
279323
print("\n── test_four_digit_line_numbers_are_not_clipped ────────────────")
280324
target_count = 1000
281325
needed = max(0, target_count - current_count)
@@ -293,20 +337,63 @@ def test_four_digit_line_numbers_are_not_clipped(gui, datadir, current_count):
293337
f"Expected at least {target_count} lines after append, got {count}"
294338
)
295339

296-
gui.wait_for_property(
297-
"debugLogListView_lineNumber_999", "text", str(target_count), timeout_ms=3000
298-
)
299-
line_number_width = gui.get_property("debugLogListView_lineNumber_999", "width")
300-
line_number_implicit_width = gui.get_property(
301-
"debugLogListView_lineNumber_999", "implicitWidth"
340+
last_index = count - 1
341+
last_line_number = f"debugLogListView_lineNumber_{last_index}"
342+
gui.invoke("debugLogListView", "scrollToTop")
343+
gui.wait_for_property("debugLogListView", "atTop", True, timeout_ms=3000)
344+
assert not gui.object_exists(last_line_number), (
345+
f"Expected offscreen row {last_index} not to be instantiated at the top"
302346
)
347+
348+
gui.invoke("debugLogListView", "scrollToBottom")
349+
gui.wait_for_property("debugLogListView", "atBottom", True, timeout_ms=3000)
350+
gui.wait_for_property(last_line_number, "text", str(count), timeout_ms=3000)
351+
line_number_width = gui.get_property(last_line_number, "width")
352+
line_number_implicit_width = gui.get_property(last_line_number, "implicitWidth")
303353
assert line_number_width >= line_number_implicit_width, (
304354
f"Expected four-digit line number width {line_number_width} to fit "
305355
f"implicit width {line_number_implicit_width}"
306356
)
307357
assert gui.get_property("debugLogListView", "effectiveLineNumberWidth") > 20, \
308358
"Expected effective line-number slot to expand beyond the design minimum"
309-
print(" PASSED: four-digit line numbers fit expanded line-number slot")
359+
print(" PASSED: offscreen rows are virtualized and four-digit line numbers fit")
360+
361+
362+
def test_load_more_at_bottom(gui, current_count):
363+
"""The bottom affordance appends older rows without moving the viewport."""
364+
print("\n── test_load_more_at_bottom ───────────────────────")
365+
gui.invoke("debugLogListView", "scrollToBottom")
366+
gui.wait_for_property("debugLogListView", "atBottom", True, timeout_ms=3000)
367+
gui.wait_for_property("debugLogLoadMoreButton", "visible", True, timeout_ms=3000)
368+
content_y_before = gui.get_property("debugLogListView", "contentY")
369+
370+
gui.click("debugLogLoadMoreButton")
371+
expanded_count = gui.wait_for_property(
372+
"debugLogListView", "count", lambda c: c > current_count, timeout_ms=5000
373+
)
374+
content_y_after = gui.get_property("debugLogListView", "contentY")
375+
assert_close(
376+
content_y_after,
377+
content_y_before,
378+
"load-more viewport anchor",
379+
tolerance=2,
380+
)
381+
gui.wait_for_property("debugLogLoadMoreButton", "visible", False, timeout_ms=3000)
382+
383+
oldest_index = expanded_count - 1
384+
oldest_message = f"debugLogListView_commandlessMessage_{oldest_index}"
385+
gui.invoke("debugLogListView", "scrollToBottom")
386+
gui.wait_for_property(
387+
oldest_message,
388+
"text",
389+
"test-automation seeded-history marker 0",
390+
timeout_ms=3000,
391+
)
392+
print(
393+
f" PASSED: loaded {expanded_count - current_count} older rows without "
394+
"moving the viewport, including the oldest seeded row"
395+
)
396+
return expanded_count
310397

311398

312399
def test_close_settings(gui):
@@ -363,9 +450,10 @@ def run_tests():
363450
total = test_log_has_entries(gui)
364451
test_search_layout_matches_design(gui)
365452
test_search_filter(gui, total)
366-
total = test_refresh_button(gui, total)
367453
total = test_auto_refresh(gui, harness.datadir, total)
454+
total = test_refresh_button(gui, total)
368455
test_four_digit_line_numbers_are_not_clipped(gui, harness.datadir, total)
456+
total = test_load_more_at_bottom(gui, total)
369457
test_close_settings(gui)
370458

371459
print("\n" + "=" * 50)

test/qml/bitcoin_qmltests.qrc

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
<file>tst_createpassword.qml</file>
1515
<file>tst_createtypeselector.qml</file>
1616
<file>tst_createwalletwizard.qml</file>
17+
<file>tst_debuglogoutputview.qml</file>
1718
<file>tst_desktopwallets.qml</file>
1819
<file>tst_displaysettings.qml</file>
1920
<file>tst_dropdownbutton.qml</file>

0 commit comments

Comments
 (0)