diff --git a/l4d2web/l4d2web/static/js/console-history.js b/l4d2web/l4d2web/static/js/console-history.js index 70e3907..7ce652d 100644 --- a/l4d2web/l4d2web/static/js/console-history.js +++ b/l4d2web/l4d2web/static/js/console-history.js @@ -174,7 +174,21 @@ function scrollAutoscrollTargets(root) { if (up) targets.push(up); } targets.forEach((el) => { - el.scrollTop = el.scrollHeight; + // The element marked data-autoscroll may not itself be the actual + // scrolling container. In the inline Log tab, .log-stream has + // overflow:auto AND max-height:none — CSS says scrollable, but it + // grows to fit content (scrollHeight === clientHeight). The real + // scrollbar lives on the .tab-pane (height:18rem, overflow:auto). + // Walk up until we find an element that both CAN scroll (CSS) and + // IS scrolling (content overflows). + let scroller = el; + while (scroller && scroller !== document.body) { + const oy = getComputedStyle(scroller).overflowY; + const cssScrolls = oy === "auto" || oy === "scroll" || oy === "overlay"; + if (cssScrolls && scroller.scrollHeight > scroller.clientHeight) break; + scroller = scroller.parentElement; + } + if (scroller) scroller.scrollTop = scroller.scrollHeight; }); } diff --git a/l4d2web/l4d2web/static/js/sse.js b/l4d2web/l4d2web/static/js/sse.js index 47ac76b..d1da511 100644 --- a/l4d2web/l4d2web/static/js/sse.js +++ b/l4d2web/l4d2web/static/js/sse.js @@ -12,7 +12,15 @@ function streamTextToElement(element) { const appendLine = (line) => { element.textContent += `${line}\n`; - element.scrollTop = element.scrollHeight; + // Delegate to the shared autoscroll helper so the actual scroll + // container (which may be an ancestor — see scrollAutoscrollTargets) + // is the one moved. console-history.js is `defer`red and loads after + // DCL, so the helper is defined before any SSE event fires. + if (window.scrollAutoscrollTargets) { + window.scrollAutoscrollTargets(element); + } else { + element.scrollTop = element.scrollHeight; + } }; source.onmessage = (event) => { diff --git a/l4d2web/tests/e2e/test_server_detail.py b/l4d2web/tests/e2e/test_server_detail.py index 766b2d6..ee500b7 100644 --- a/l4d2web/tests/e2e/test_server_detail.py +++ b/l4d2web/tests/e2e/test_server_detail.py @@ -161,3 +161,35 @@ def test_console_pane_pinned_after_command_submit(page: Page, server_with_consol "(el) => el.scrollHeight - el.scrollTop - el.clientHeight" ) assert abs(bottom_distance) < 2, f"transcript not pinned after submit: {bottom_distance}px" + + +def test_log_pane_pinned_after_lines_appended(page: Page, server_with_files) -> None: + """The inline Log tab's scroll container is .tab-pane (not the
+ itself, which has max-height:none in tab-pane context). Inject log
+ lines into the and call the autoscroll helper, then assert the
+ .tab-pane — the actual scroller — is pinned to its bottom.
+
+ Real L4D2 SSE isn't available in e2e, so we drive the autoscroll path
+ by hand. This pins the contract that scrollAutoscrollTargets walks up
+ to the nearest CSS-scrollable ancestor.
+ """
+ base = server_with_files["base_url"]
+ sid = server_with_files["server_id"]
+ login(page, base)
+ page.goto(f"{base}/servers/{sid}")
+
+ # Inject enough text to overflow the 18rem tab-pane, then run the
+ # helper. The Log tab is the default-active tab; no click needed.
+ page.evaluate(
+ """() => {
+ const pre = document.querySelector('[role="tabpanel"][data-tab="log"] .log-stream');
+ pre.textContent = Array.from({length: 200}, (_, i) => `log line ${i}`).join('\\n');
+ window.scrollAutoscrollTargets(pre);
+ }"""
+ )
+
+ pane = page.locator('[role="tabpanel"][data-tab="log"]')
+ bottom_distance = pane.evaluate(
+ "(el) => el.scrollHeight - el.scrollTop - el.clientHeight"
+ )
+ assert abs(bottom_distance) < 2, f"log pane not pinned to bottom: {bottom_distance}px"