"""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/, 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//merged/ directory via the dedicated /servers//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
  • 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/ 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 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
        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"