"""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"