left4me/l4d2web/tests/e2e/test_server_detail.py
mwiegand 8f5306db09
fix(server-detail): scroll the actual container, not the autoscroll target
The inline Log tab uses .tab-pane (height:18rem, overflow:auto) as its
scroll container. .log-stream has overflow:auto too but max-height:none
in tab-pane context, so it grows to fit and scrollHeight === clientHeight
— setting scrollTop on the <pre> was a no-op.

scrollAutoscrollTargets now walks up from each [data-autoscroll] target
until it finds an element whose CSS allows scrolling AND whose content
is actually overflowing (scrollHeight > clientHeight). sse.js delegates
to the same helper so per-line log appends scroll the right container.

e2e: new test asserts the .tab-pane is pinned to its bottom after 200
log lines are injected and the helper runs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 09:53:06 +02:00

195 lines
8 KiB
Python

"""End-to-end Playwright tests for the server detail page.
Distinct from test_files_overlay.py because the server-detail page
is NOT a files-overlay (`files_overlay=False` in the template). It
reuses the same `_overlay_file_tree.html` partial but renders rows
in read-only mode — no edit button on filenames, no delete action,
only the download anchor in the per-row action strip.
Tier 3 from docs/superpowers/plans/2026-05-17-files-overlay-e2e-handoff.md.
"""
from __future__ import annotations
import pytest
from playwright.sync_api import Page, expect
from .conftest import login
pytestmark = pytest.mark.e2e
def test_hover_download_initiates_file_download(page: Page, server_with_files) -> None:
"""Navigate to /servers/<id>, hover a file row to defeat the
`opacity: 0; pointer-events: none` CSS gate on `.files-row-actions`,
click the ⬇ download link, and assert the browser receives a file
download with the expected filename + bytes.
Pins the contract that the read-only file tree on server-detail
pages can download files served from the runtime/<id>/merged/
directory via the dedicated /servers/<id>/files/download
endpoint (separate from the files-overlay download path).
The CSS hover gate is non-decorative: without `locator.hover()`
first, `pointer-events: none` makes the link unclickable. A
regression that ships row actions always-visible (or always-hidden)
would change the user-flow ergonomics; if the gate semantics ever
move, this test needs an update.
"""
base = server_with_files["base_url"]
server_id = server_with_files["server_id"]
merged_root = server_with_files["merged_root"]
expected_bytes = (merged_root / "server.cfg").read_bytes()
login(page, base)
page.goto(f"{base}/servers/{server_id}")
# The file tree lives inside the Files tab pane (hidden by default).
# Switch to it first so the row is visible and hover-clickable.
strip = page.locator("[data-tab-strip]")
strip.locator('[role="tab"][data-tab="files"]').click()
# On server detail, files_overlay=False in the template, so the
# row <li> has no data-target-path. Scope to the tab pane (not the
# expand modal which also contains a copy of the file tree) and match
# by row class + visible filename text.
files_pane = strip.locator('[role="tabpanel"][data-tab="files"]')
row = files_pane.locator("li.file-tree-row-file", has_text="server.cfg")
expect(row).to_be_visible(timeout=5000)
row.hover()
download_link = row.locator('a.files-row-action[title="Download"]')
with page.expect_download() as dl_info:
download_link.click()
download = dl_info.value
assert download.suggested_filename == "server.cfg"
# Playwright auto-saves to a temp dir we can read back from.
assert download.path().read_bytes() == expected_bytes
def test_tabs_switch_between_log_console_files(page: Page, server_with_files) -> None:
"""The inspection strip on /servers/<id> defaults to the Log tab and
switches tab + tabpane visibility on click. Active-tab state is
mirrored on the strip via data-active-tab.
"""
base = server_with_files["base_url"]
sid = server_with_files["server_id"]
login(page, base)
page.goto(f"{base}/servers/{sid}")
strip = page.locator("[data-tab-strip]")
expect(strip).to_be_visible()
expect(strip).to_have_attribute("data-active-tab", "log")
# Click Console
strip.locator('[role="tab"][data-tab="console"]').click()
expect(strip).to_have_attribute("data-active-tab", "console")
expect(strip.locator('[role="tabpanel"][data-tab="console"]')).to_be_visible()
expect(strip.locator('[role="tabpanel"][data-tab="log"]')).to_be_hidden()
# Click Files
strip.locator('[role="tab"][data-tab="files"]').click()
expect(strip).to_have_attribute("data-active-tab", "files")
expect(strip.locator('[role="tabpanel"][data-tab="files"]')).to_be_visible()
def test_expand_opens_matching_modal(page: Page, server_with_files) -> None:
"""Clicking ⛶ on the inspection strip opens the <dialog> whose id
matches the active tab name (data-active-tab="files" → #files-modal).
"""
base = server_with_files["base_url"]
sid = server_with_files["server_id"]
login(page, base)
page.goto(f"{base}/servers/{sid}")
strip = page.locator("[data-tab-strip]")
strip.locator('[role="tab"][data-tab="files"]').click()
strip.locator(".strip-expand").click()
dialog = page.locator("dialog#files-modal")
expect(dialog).to_have_attribute("open", "")
def test_console_tab_pinned_to_bottom_on_activation(page: Page, server_with_console_history) -> None:
"""Clicking the Console tab leaves the transcript scrolled to its
bottom — the newest seeded command must be visible, not the oldest."""
base = server_with_console_history["base_url"]
sid = server_with_console_history["server_id"]
login(page, base)
page.goto(f"{base}/servers/{sid}")
strip = page.locator("[data-tab-strip]")
strip.locator('[role="tab"][data-tab="console"]').click()
transcript = page.locator(f"#console-transcript-inline-{sid}")
expect(transcript).to_be_visible()
bottom_distance = transcript.evaluate(
"(el) => el.scrollHeight - el.scrollTop - el.clientHeight"
)
assert abs(bottom_distance) < 2, f"transcript not pinned to bottom: {bottom_distance}px"
line_count = transcript.locator(".console-line").count()
assert line_count == 20, f"inline expected 20 lines, got {line_count}"
def test_console_pane_pinned_after_command_submit(page: Page, server_with_console_history) -> None:
"""After submitting a command, the transcript scrolls so the new line
is visible at the bottom.
The dev server has no live RCON, but the POST still records a
CommandHistory row and HTMX appends a console-line to the transcript;
that's enough to exercise the autoscroll path.
"""
base = server_with_console_history["base_url"]
sid = server_with_console_history["server_id"]
login(page, base)
page.goto(f"{base}/servers/{sid}")
strip = page.locator("[data-tab-strip]")
strip.locator('[role="tab"][data-tab="console"]').click()
transcript = page.locator(f"#console-transcript-inline-{sid}")
pane = page.locator('[role="tabpanel"][data-tab="console"]')
cmd_input = pane.locator('input[name="command"]')
cmd_input.fill("verify_submit")
cmd_input.press("Enter")
expect(transcript.locator(".console-line", has_text="verify_submit")).to_be_visible()
bottom_distance = transcript.evaluate(
"(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 <pre>
itself, which has max-height:none in tab-pane context). Inject log
lines into the <pre> 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"