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>
195 lines
8 KiB
Python
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"
|