Files
compose-farm/tests/web/test_htmx_browser.py

2566 lines
98 KiB
Python

"""Browser tests for HTMX behavior using Playwright.
Run with: uv run pytest tests/web/test_htmx_browser.py -v --no-cov
CDN assets are cached locally (in .pytest_cache/vendor/) to eliminate network
variability. If a test fails with "Uncached CDN request", add the URL to
src/compose_farm/web/vendor-assets.json.
"""
from __future__ import annotations
import os
import re
import shutil
import socket
import threading
import time
import urllib.request
from collections.abc import Generator
from pathlib import Path
from typing import TYPE_CHECKING
import pytest
import uvicorn
from compose_farm.config import load_config
from compose_farm.web import deps as web_deps
from compose_farm.web.app import create_app
from compose_farm.web.cdn import CDN_ASSETS, ensure_vendor_cache
from compose_farm.web.routes import api as web_api
from compose_farm.web.routes import pages as web_pages
if TYPE_CHECKING:
from playwright.sync_api import Page, Route, WebSocket
# Default timeout for Playwright waits (ms) - higher for CI stability
TIMEOUT = 10000
SHORT_TIMEOUT = 5000 # For quick UI transitions (palette, dialogs)
def _browser_available() -> bool:
"""Check if any chromium browser is available (system or Playwright-managed)."""
# Check for system browser
if shutil.which("chromium") or shutil.which("google-chrome"):
return True
# Check for Playwright-managed browser
try:
from playwright._impl._driver import compute_driver_executable
driver_info = compute_driver_executable()
# compute_driver_executable returns (driver_path, browser_path) tuple
driver_path = driver_info[0] if isinstance(driver_info, tuple) else driver_info
return Path(driver_path).exists()
except Exception:
return False
# Mark all tests as browser tests and skip if no browser available
pytestmark = [
pytest.mark.browser,
pytest.mark.skipif(
not _browser_available(),
reason="No browser available (install via: playwright install chromium --with-deps)",
),
]
@pytest.fixture(scope="session")
def vendor_cache(request: pytest.FixtureRequest) -> Path:
"""Download CDN assets once and cache to disk for faster tests."""
cache_dir = Path(request.config.rootpath) / ".pytest_cache" / "vendor"
return ensure_vendor_cache(cache_dir)
@pytest.fixture
def page(page: Page, vendor_cache: Path) -> Page:
"""Override default page fixture to intercept CDN requests with local cache.
Any CDN request not in CDN_ASSETS will abort with an error, forcing developers
to add new CDN URLs to the cache. This catches both static and dynamic loads.
"""
cache = {url: (vendor_cache / f, ct) for url, (f, ct) in CDN_ASSETS.items()}
def handle_cdn(route: Route) -> None:
url = route.request.url
for url_prefix, (filepath, content_type) in cache.items():
if url.startswith(url_prefix):
route.fulfill(status=200, content_type=content_type, body=filepath.read_bytes())
return
# Uncached CDN request - abort with helpful error
route.abort("failed")
msg = f"Uncached CDN request: {url}\n\nAdd this URL to src/compose_farm/web/vendor-assets.json"
raise RuntimeError(msg)
page.route(re.compile(r"https://(cdn\.jsdelivr\.net|unpkg\.com)/.*"), handle_cdn)
return page
@pytest.fixture(scope="session")
def browser_type_launch_args() -> dict[str, str]:
"""Configure Playwright to use system Chromium if available, else use bundled."""
# Prefer system browser if available (for nix-shell usage)
for name in ["chromium", "chromium-browser", "google-chrome", "chrome"]:
path = shutil.which(name)
if path:
return {"executable_path": path}
# Fall back to Playwright's bundled browser (for CI)
return {}
@pytest.fixture(scope="module")
def test_config(tmp_path_factory: pytest.TempPathFactory) -> Path:
"""Create test config and compose files.
Creates a multi-host, multi-stack config for comprehensive testing:
- server-1: plex (running), grafana (not started)
- server-2: nextcloud (running), jellyfin (not started)
"""
tmp: Path = tmp_path_factory.mktemp("data")
# Create compose dir with stacks
compose_dir = tmp / "compose"
compose_dir.mkdir()
for name in ["plex", "grafana", "nextcloud", "jellyfin", "redis"]:
svc = compose_dir / name
svc.mkdir()
if name == "plex":
# Multi-service stack for testing service commands
# Includes hyphenated name (plex-server) to test word-boundary matching
(svc / "compose.yaml").write_text(
"services:\n plex-server:\n image: test/plex\n redis:\n image: redis:alpine\n"
)
else:
(svc / "compose.yaml").write_text(f"services:\n {name}:\n image: test/{name}\n")
# Create glances stack (required for containers page)
glances_dir = compose_dir / "glances"
glances_dir.mkdir()
(glances_dir / "compose.yaml").write_text(
"services:\n glances:\n image: nicolargo/glances\n"
)
# Create config with multiple hosts
config = tmp / "compose-farm.yaml"
config.write_text(f"""
compose_dir: {compose_dir}
hosts:
server-1:
address: 192.168.1.10
user: docker
server-2:
address: 192.168.1.20
user: docker
stacks:
plex: server-1
grafana: server-1
nextcloud: server-2
jellyfin: server-2
redis: server-1
glances: all
glances_stack: glances
""")
# Create state (plex and nextcloud running, grafana and jellyfin not started)
(tmp / "compose-farm-state.yaml").write_text(
"deployed:\n plex: server-1\n nextcloud: server-2\n"
)
return config
@pytest.fixture(scope="module")
def server_url(
test_config: Path, monkeypatch_module: pytest.MonkeyPatch
) -> Generator[str, None, None]:
"""Start test server and return URL."""
# Load the test config
config = load_config(test_config)
# Patch get_config in all modules that import it
monkeypatch_module.setattr(web_deps, "get_config", lambda: config)
monkeypatch_module.setattr(web_api, "get_config", lambda: config)
monkeypatch_module.setattr(web_pages, "get_config", lambda: config)
# Also set CF_CONFIG for any code that reads it directly
os.environ["CF_CONFIG"] = str(test_config)
# Find free port
with socket.socket() as s:
s.bind(("127.0.0.1", 0))
port = s.getsockname()[1]
app = create_app()
uvicorn_config = uvicorn.Config(app, host="127.0.0.1", port=port, log_level="error")
server = uvicorn.Server(uvicorn_config)
# Run in thread
thread = threading.Thread(target=server.run, daemon=True)
thread.start()
# Wait for startup with proper error handling
url = f"http://127.0.0.1:{port}"
server_ready = False
for _ in range(100): # 2 seconds max
try:
urllib.request.urlopen(url, timeout=0.1) # noqa: S310
server_ready = True
break
except Exception:
time.sleep(0.02) # 20ms between checks
if not server_ready:
msg = f"Test server failed to start on {url}"
raise RuntimeError(msg)
yield url
server.should_exit = True
thread.join(timeout=2)
# Clean up env
os.environ.pop("CF_CONFIG", None)
@pytest.fixture(scope="module")
def monkeypatch_module() -> Generator[pytest.MonkeyPatch, None, None]:
"""Module-scoped monkeypatch."""
mp = pytest.MonkeyPatch()
yield mp
mp.undo()
class TestHTMXSidebarLoading:
"""Test that sidebar loads dynamically via HTMX."""
def test_sidebar_initially_shows_loading(self, page: Page, server_url: str) -> None:
"""Sidebar shows loading spinner before HTMX loads content."""
# Intercept the sidebar request to delay it
page.route("**/partials/sidebar", lambda route: route.abort())
page.goto(server_url)
# Before HTMX loads, should see loading indicator
nav = page.locator("nav")
assert "Loading" in nav.inner_text() or nav.locator(".loading").count() > 0
def test_sidebar_loads_stacks_via_htmx(self, page: Page, server_url: str) -> None:
"""Sidebar fetches and displays stacks via hx-get on load."""
page.goto(server_url)
# Wait for HTMX to load sidebar content
page.wait_for_selector("#sidebar-stacks", timeout=TIMEOUT)
# Verify actual stacks from test config appear
stacks = page.locator("#sidebar-stacks li")
assert stacks.count() == 6 # plex, grafana, nextcloud, jellyfin, redis, glances
# Check specific stacks are present
content = page.locator("#sidebar-stacks").inner_text()
assert "plex" in content
assert "grafana" in content
assert "nextcloud" in content
assert "jellyfin" in content
def test_dashboard_content_persists_after_sidebar_loads(
self, page: Page, server_url: str
) -> None:
"""Dashboard content must remain visible after HTMX loads sidebar.
Regression test: conflicting hx-select attributes on the nav element
were causing the dashboard to disappear when sidebar loaded.
"""
page.goto(server_url)
# Dashboard content should be visible immediately (server-rendered)
stats = page.locator("#stats-cards")
assert stats.is_visible()
# Wait for sidebar to fully load via HTMX
page.wait_for_selector("#sidebar-stacks", timeout=TIMEOUT)
# Dashboard content must STILL be visible after sidebar loads
assert stats.is_visible(), "Dashboard disappeared after sidebar loaded"
assert page.locator("#stats-cards .card").count() >= 4
def test_sidebar_shows_running_status(self, page: Page, server_url: str) -> None:
"""Sidebar shows running/stopped status indicators for stacks."""
page.goto(server_url)
page.wait_for_selector("#sidebar-stacks", timeout=TIMEOUT)
# plex and nextcloud are in state (running) - should have success status
plex_item = page.locator("#sidebar-stacks li", has_text="plex")
assert plex_item.locator(".status-success").count() == 1
nextcloud_item = page.locator("#sidebar-stacks li", has_text="nextcloud")
assert nextcloud_item.locator(".status-success").count() == 1
# grafana and jellyfin are NOT in state (not started) - should have neutral status
grafana_item = page.locator("#sidebar-stacks li", has_text="grafana")
assert grafana_item.locator(".status-neutral").count() == 1
jellyfin_item = page.locator("#sidebar-stacks li", has_text="jellyfin")
assert jellyfin_item.locator(".status-neutral").count() == 1
class TestHTMXBoostNavigation:
"""Test hx-boost SPA-like navigation."""
def test_navigation_updates_url_without_full_reload(self, page: Page, server_url: str) -> None:
"""Clicking boosted link updates URL without full page reload."""
page.goto(server_url)
page.wait_for_selector("#sidebar-stacks a", timeout=TIMEOUT)
# Add a marker to detect full page reload
page.evaluate("window.__htmxTestMarker = 'still-here'")
# Click a stack link (boosted via hx-boost on parent)
page.locator("#sidebar-stacks a", has_text="plex").click()
# Wait for navigation
page.wait_for_url("**/stack/plex", timeout=TIMEOUT)
# Verify URL changed
assert "/stack/plex" in page.url
# Verify NO full page reload (marker should still exist)
marker = page.evaluate("window.__htmxTestMarker")
assert marker == "still-here", "Full page reload occurred - hx-boost not working"
def test_main_content_replaced_on_navigation(self, page: Page, server_url: str) -> None:
"""Navigation replaces #main-content via hx-target/hx-select."""
page.goto(server_url)
page.wait_for_selector("#sidebar-stacks a", timeout=TIMEOUT)
# Get initial main content
initial_content = page.locator("#main-content").inner_text()
assert "Compose Farm" in initial_content # Dashboard title
# Navigate to stack page
page.locator("#sidebar-stacks a", has_text="plex").click()
page.wait_for_url("**/stack/plex", timeout=TIMEOUT)
# Main content should now show stack page
new_content = page.locator("#main-content").inner_text()
assert "plex" in new_content.lower()
assert "Compose Farm" not in new_content # Dashboard title should be gone
class TestDashboardContent:
"""Test dashboard displays correct data."""
def test_stats_show_correct_counts(self, page: Page, server_url: str) -> None:
"""Stats cards show accurate host/stack counts from config."""
page.goto(server_url)
page.wait_for_selector("#stats-cards", timeout=TIMEOUT)
stats = page.locator("#stats-cards").inner_text()
# From test config: 2 hosts, 5 stacks, 2 running (plex, nextcloud)
assert "2" in stats # hosts count
assert "6" in stats # stacks count
def test_pending_shows_not_started_stacks(self, page: Page, server_url: str) -> None:
"""Pending operations shows grafana and jellyfin as not started."""
page.goto(server_url)
page.wait_for_selector("#pending-operations", timeout=TIMEOUT)
pending = page.locator("#pending-operations")
content = pending.inner_text().lower()
# grafana and jellyfin are not in state, should show as not started
assert "grafana" in content or "not started" in content
assert "jellyfin" in content or "not started" in content
def test_dashboard_monaco_loads(self, page: Page, server_url: str) -> None:
"""Dashboard page loads Monaco editor for config editing."""
page.goto(server_url)
page.wait_for_selector("#sidebar-stacks", timeout=TIMEOUT)
# Wait for Monaco to load
page.wait_for_function("typeof monaco !== 'undefined'", timeout=TIMEOUT)
# Verify Monaco editor element exists (may be in collapsed section)
page.wait_for_function(
"document.querySelectorAll('.monaco-editor').length >= 1",
timeout=TIMEOUT,
)
assert page.locator(".monaco-editor").count() >= 1
class TestSaveConfigButton:
"""Test save config button behavior."""
def test_save_button_shows_saved_feedback(self, page: Page, server_url: str) -> None:
"""Clicking save shows 'Saved!' feedback text."""
page.goto(server_url)
page.wait_for_selector("#save-config-btn", timeout=TIMEOUT)
save_btn = page.locator("#save-config-btn")
initial_text = save_btn.inner_text()
assert "Save" in initial_text
# Click save
save_btn.click()
# Wait for feedback
page.wait_for_function(
"document.querySelector('#save-config-btn')?.textContent?.includes('Saved')",
timeout=TIMEOUT,
)
# Verify feedback shown
assert "Saved" in save_btn.inner_text()
class TestStackDetailPage:
"""Test stack detail page via HTMX navigation."""
def test_stack_page_shows_stack_info(self, page: Page, server_url: str) -> None:
"""Stack page displays stack information."""
page.goto(server_url)
page.wait_for_selector("#sidebar-stacks a", timeout=TIMEOUT)
# Navigate to plex stack
page.locator("#sidebar-stacks a", has_text="plex").click()
page.wait_for_url("**/stack/plex", timeout=TIMEOUT)
# Should show stack name and host info
content = page.locator("#main-content").inner_text()
assert "plex" in content.lower()
assert "server-1" in content # assigned host from config
# Should show compose file path
assert "compose.yaml" in content
def test_back_navigation_works(self, page: Page, server_url: str) -> None:
"""Browser back button works after HTMX navigation."""
page.goto(server_url)
page.wait_for_selector("#sidebar-stacks a", timeout=TIMEOUT)
# Navigate to stack
page.locator("#sidebar-stacks a", has_text="plex").click()
page.wait_for_url("**/stack/plex", timeout=TIMEOUT)
# Go back
page.go_back()
page.wait_for_url(server_url, timeout=TIMEOUT)
# Should be back on dashboard
assert page.url.rstrip("/") == server_url.rstrip("/")
def test_stack_page_monaco_loads(self, page: Page, server_url: str) -> None:
"""Stack page loads Monaco editor for compose/env editing."""
page.goto(server_url)
page.wait_for_selector("#sidebar-stacks a", timeout=TIMEOUT)
# Navigate to plex stack
page.locator("#sidebar-stacks a", has_text="plex").click()
page.wait_for_url("**/stack/plex", timeout=TIMEOUT)
# Wait for Monaco to load
page.wait_for_function("typeof monaco !== 'undefined'", timeout=TIMEOUT)
# Verify Monaco editor element exists (may be in collapsed section)
page.wait_for_function(
"document.querySelectorAll('.monaco-editor').length >= 1",
timeout=TIMEOUT,
)
assert page.locator(".monaco-editor").count() >= 1
class TestSidebarFilter:
"""Test JavaScript sidebar filtering functionality."""
@staticmethod
def _filter_sidebar(page: Page, text: str) -> None:
"""Fill the sidebar filter and trigger the keyup event.
The sidebar uses onkeyup, which fill() doesn't trigger.
"""
filter_input = page.locator("#sidebar-filter")
filter_input.fill(text)
filter_input.dispatch_event("keyup")
def test_text_filter_hides_non_matching_stacks(self, page: Page, server_url: str) -> None:
"""Typing in filter input hides stacks that don't match."""
page.goto(server_url)
page.wait_for_selector("#sidebar-stacks", timeout=TIMEOUT)
# Initially all 6 stacks visible
visible_items = page.locator("#sidebar-stacks li:not([hidden])")
assert visible_items.count() == 6
# Type in filter to match only "plex"
self._filter_sidebar(page, "plex")
# Only plex should be visible now
visible_after = page.locator("#sidebar-stacks li:not([hidden])")
assert visible_after.count() == 1
assert "plex" in visible_after.first.inner_text()
def test_text_filter_updates_count_badge(self, page: Page, server_url: str) -> None:
"""Filter updates the stack count badge."""
page.goto(server_url)
page.wait_for_selector("#sidebar-stacks", timeout=TIMEOUT)
# Initial count should be (6)
count_badge = page.locator("#sidebar-count")
assert "(6)" in count_badge.inner_text()
# Filter to show only stacks containing "x" (plex, nextcloud)
self._filter_sidebar(page, "x")
# Count should update to (2)
assert "(2)" in count_badge.inner_text()
def test_text_filter_is_case_insensitive(self, page: Page, server_url: str) -> None:
"""Filter matching is case-insensitive."""
page.goto(server_url)
page.wait_for_selector("#sidebar-stacks", timeout=TIMEOUT)
# Type uppercase
self._filter_sidebar(page, "PLEX")
# Should still match plex
visible = page.locator("#sidebar-stacks li:not([hidden])")
assert visible.count() == 1
assert "plex" in visible.first.inner_text().lower()
def test_host_dropdown_filters_by_host(self, page: Page, server_url: str) -> None:
"""Host dropdown filters stacks by their assigned host."""
page.goto(server_url)
page.wait_for_selector("#sidebar-stacks", timeout=TIMEOUT)
# Select server-1 from dropdown
page.locator("#sidebar-host-select").select_option("server-1")
# plex, grafana, redis (server-1), and glances (all) should be visible
visible = page.locator("#sidebar-stacks li:not([hidden])")
assert visible.count() == 4
content = visible.all_inner_texts()
assert any("plex" in s for s in content)
assert any("grafana" in s for s in content)
assert any("glances" in s for s in content)
assert not any("nextcloud" in s for s in content)
assert not any("jellyfin" in s for s in content)
def test_combined_text_and_host_filter(self, page: Page, server_url: str) -> None:
"""Text filter and host filter work together."""
page.goto(server_url)
page.wait_for_selector("#sidebar-stacks", timeout=TIMEOUT)
# Filter by server-2 host
page.locator("#sidebar-host-select").select_option("server-2")
# Then filter by text "next" (should match only nextcloud on server-2)
self._filter_sidebar(page, "next")
visible = page.locator("#sidebar-stacks li:not([hidden])")
assert visible.count() == 1
assert "nextcloud" in visible.first.inner_text()
def test_clearing_filter_shows_all_stacks(self, page: Page, server_url: str) -> None:
"""Clearing filter restores all stacks."""
page.goto(server_url)
page.wait_for_selector("#sidebar-stacks", timeout=TIMEOUT)
# Apply filter
self._filter_sidebar(page, "plex")
assert page.locator("#sidebar-stacks li:not([hidden])").count() == 1
# Clear filter
self._filter_sidebar(page, "")
# All stacks visible again
assert page.locator("#sidebar-stacks li:not([hidden])").count() == 6
class TestCommandPalette:
"""Test command palette (Cmd+K) JavaScript functionality."""
def test_cmd_k_opens_palette(self, page: Page, server_url: str) -> None:
"""Cmd+K keyboard shortcut opens the command palette."""
page.goto(server_url)
page.wait_for_selector("#sidebar-stacks", timeout=TIMEOUT)
# Palette should be closed initially
assert not page.locator("#cmd-palette").is_visible()
# Press Cmd+K (Meta+k on Mac, Control+k otherwise)
page.keyboard.press("Control+k")
# Palette should now be open
page.wait_for_selector("#cmd-palette[open]", timeout=SHORT_TIMEOUT)
assert page.locator("#cmd-palette").is_visible()
def test_palette_input_is_focused_on_open(self, page: Page, server_url: str) -> None:
"""Input field is focused when palette opens."""
page.goto(server_url)
page.wait_for_selector("#sidebar-stacks", timeout=TIMEOUT)
page.keyboard.press("Control+k")
page.wait_for_selector("#cmd-palette[open]", timeout=SHORT_TIMEOUT)
# Input should be focused - we can type directly
page.keyboard.type("test")
assert page.locator("#cmd-input").input_value() == "test"
def test_palette_shows_navigation_commands(self, page: Page, server_url: str) -> None:
"""Palette shows Dashboard and Console navigation commands."""
page.goto(server_url)
page.wait_for_selector("#sidebar-stacks", timeout=TIMEOUT)
page.keyboard.press("Control+k")
page.wait_for_selector("#cmd-palette[open]", timeout=SHORT_TIMEOUT)
cmd_list = page.locator("#cmd-list").inner_text()
assert "Dashboard" in cmd_list
assert "Console" in cmd_list
def test_palette_shows_stack_navigation(self, page: Page, server_url: str) -> None:
"""Palette includes stack names for navigation."""
page.goto(server_url)
page.wait_for_selector("#sidebar-stacks", timeout=TIMEOUT)
page.keyboard.press("Control+k")
page.wait_for_selector("#cmd-palette[open]", timeout=SHORT_TIMEOUT)
cmd_list = page.locator("#cmd-list").inner_text()
# Stacks should appear as navigation options
assert "plex" in cmd_list
assert "nextcloud" in cmd_list
def test_palette_filters_on_input(self, page: Page, server_url: str) -> None:
"""Typing in palette filters the command list."""
page.goto(server_url)
page.wait_for_selector("#sidebar-stacks", timeout=TIMEOUT)
page.keyboard.press("Control+k")
page.wait_for_selector("#cmd-palette[open]", timeout=SHORT_TIMEOUT)
# Type to filter
page.locator("#cmd-input").fill("plex")
# Should show plex, hide others
cmd_list = page.locator("#cmd-list").inner_text()
assert "plex" in cmd_list
assert "Dashboard" not in cmd_list # Filtered out
def test_arrow_down_moves_selection(self, page: Page, server_url: str) -> None:
"""Arrow down key moves selection to next item."""
page.goto(server_url)
page.wait_for_selector("#sidebar-stacks", timeout=TIMEOUT)
page.keyboard.press("Control+k")
page.wait_for_selector("#cmd-palette[open]", timeout=SHORT_TIMEOUT)
# First item should be selected (has bg-base-300)
first_item = page.locator("#cmd-list a").first
assert "bg-base-300" in (first_item.get_attribute("class") or "")
# Press arrow down
page.keyboard.press("ArrowDown")
# Second item should now be selected
second_item = page.locator("#cmd-list a").nth(1)
assert "bg-base-300" in (second_item.get_attribute("class") or "")
# First should no longer be selected
assert "bg-base-300" not in (first_item.get_attribute("class") or "")
def test_enter_executes_and_closes_palette(self, page: Page, server_url: str) -> None:
"""Enter key executes selected command and closes palette."""
page.goto(server_url)
page.wait_for_selector("#sidebar-stacks", timeout=TIMEOUT)
page.keyboard.press("Control+k")
page.wait_for_selector("#cmd-palette[open]", timeout=SHORT_TIMEOUT)
# Filter to plex stack
page.locator("#cmd-input").fill("plex")
page.keyboard.press("Enter")
# Palette should close (use state="hidden" since closed dialog is not visible)
page.wait_for_selector("#cmd-palette", state="hidden", timeout=SHORT_TIMEOUT)
# Should navigate to plex stack page
page.wait_for_url("**/stack/plex", timeout=TIMEOUT)
def test_click_executes_command(self, page: Page, server_url: str) -> None:
"""Clicking a command executes it."""
page.goto(server_url)
page.wait_for_selector("#sidebar-stacks", timeout=TIMEOUT)
page.keyboard.press("Control+k")
page.wait_for_selector("#cmd-palette[open]", timeout=SHORT_TIMEOUT)
# Click on Console command
page.locator("#cmd-list a", has_text="Console").click()
# Should navigate to console page
page.wait_for_url("**/console", timeout=TIMEOUT)
def test_escape_closes_palette(self, page: Page, server_url: str) -> None:
"""Escape key closes the palette without executing."""
page.goto(server_url)
page.wait_for_selector("#sidebar-stacks", timeout=TIMEOUT)
page.keyboard.press("Control+k")
page.wait_for_selector("#cmd-palette[open]", timeout=SHORT_TIMEOUT)
page.keyboard.press("Escape")
# Palette should close, URL unchanged (use state="hidden" since closed dialog is not visible)
page.wait_for_selector("#cmd-palette", state="hidden", timeout=SHORT_TIMEOUT)
assert page.url.rstrip("/") == server_url.rstrip("/")
def test_fab_button_opens_palette(self, page: Page, server_url: str) -> None:
"""Floating action button opens the command palette."""
page.goto(server_url)
page.wait_for_selector("#sidebar-stacks", timeout=TIMEOUT)
# Click the FAB
page.locator("#cmd-fab").click()
# Palette should open
page.wait_for_selector("#cmd-palette[open]", timeout=SHORT_TIMEOUT)
class TestActionButtons:
"""Test action button HTMX POST requests."""
def test_apply_button_makes_post_request(self, page: Page, server_url: str) -> None:
"""Apply button triggers POST to /api/apply."""
page.goto(server_url)
page.wait_for_selector("#sidebar-stacks", timeout=TIMEOUT)
# Intercept the API call
api_calls: list[str] = []
def handle_route(route: Route) -> None:
api_calls.append(route.request.url)
# Return a mock response
route.fulfill(
status=200,
content_type="application/json",
body='{"task_id": "test-apply-123"}',
)
page.route("**/api/apply", handle_route)
# Click Apply button
page.locator("button", has_text="Apply").click()
# Wait for request to be made
page.wait_for_timeout(500)
# Verify API was called
assert len(api_calls) == 1
assert "/api/apply" in api_calls[0]
def test_refresh_button_makes_post_request(self, page: Page, server_url: str) -> None:
"""Refresh button triggers POST to /api/refresh."""
page.goto(server_url)
page.wait_for_selector("#sidebar-stacks", timeout=TIMEOUT)
api_calls: list[str] = []
def handle_route(route: Route) -> None:
api_calls.append(route.request.url)
route.fulfill(
status=200,
content_type="application/json",
body='{"task_id": "test-refresh-123"}',
)
page.route("**/api/refresh", handle_route)
page.locator("button", has_text="Refresh").click()
page.wait_for_timeout(500)
assert len(api_calls) == 1
assert "/api/refresh" in api_calls[0]
def test_action_response_expands_terminal(self, page: Page, server_url: str) -> None:
"""Action button response with task_id expands terminal section."""
page.goto(server_url)
page.wait_for_selector("#sidebar-stacks", timeout=TIMEOUT)
# Terminal should be collapsed initially
terminal_toggle = page.locator("#terminal-toggle")
assert not terminal_toggle.is_checked()
# Mock the API to return a task_id
page.route(
"**/api/apply",
lambda route: route.fulfill(
status=200,
content_type="application/json",
body='{"task_id": "test-123"}',
),
)
# Click Apply
page.locator("button", has_text="Apply").click()
# Terminal should expand
page.wait_for_function(
"document.getElementById('terminal-toggle')?.checked === true",
timeout=SHORT_TIMEOUT,
)
def test_stack_page_action_buttons(self, page: Page, server_url: str) -> None:
"""Service page has working action buttons."""
page.goto(server_url)
page.wait_for_selector("#sidebar-stacks a", timeout=TIMEOUT)
# Navigate to plex stack
page.locator("#sidebar-stacks a", has_text="plex").click()
page.wait_for_url("**/stack/plex", timeout=TIMEOUT)
# Intercept stack-specific API calls
api_calls: list[str] = []
def handle_route(route: Route) -> None:
api_calls.append(route.request.url)
route.fulfill(
status=200,
content_type="application/json",
body='{"task_id": "test-up-123"}',
)
page.route("**/api/stack/plex/up", handle_route)
# Click Up button (use get_by_role for exact match, avoiding "Update")
page.get_by_role("button", name="Up", exact=True).click()
page.wait_for_timeout(500)
assert len(api_calls) == 1
assert "/api/stack/plex/up" in api_calls[0]
class TestKeyboardShortcuts:
"""Test global keyboard shortcuts."""
def test_ctrl_s_triggers_save(self, page: Page, server_url: str) -> None:
"""Ctrl+S triggers save when editors are present."""
page.goto(server_url)
page.wait_for_selector("#save-config-btn", timeout=TIMEOUT)
# Wait for Monaco editor to load (it takes a moment)
page.wait_for_function(
"typeof monaco !== 'undefined'",
timeout=TIMEOUT,
)
# Press Ctrl+S
page.keyboard.press("Control+s")
# Should trigger save - button shows "Saved!"
page.wait_for_function(
"document.querySelector('#save-config-btn')?.textContent?.includes('Saved')",
timeout=TIMEOUT,
)
class TestContentStability:
"""Test that HTMX operations don't accidentally destroy other page content.
These tests verify that when one element updates, other elements remain stable.
This catches bugs where HTMX attributes (hx-select, hx-swap-oob, etc.) are
misconfigured and cause unintended side effects.
"""
def test_all_dashboard_sections_visible_after_full_load(
self, page: Page, server_url: str
) -> None:
"""All dashboard sections remain visible after HTMX completes loading."""
page.goto(server_url)
# Wait for all HTMX requests to complete
page.wait_for_selector("#sidebar-stacks", timeout=TIMEOUT)
page.wait_for_load_state("networkidle")
# All major dashboard sections must be visible
assert page.locator("#stats-cards").is_visible(), "Stats cards missing"
assert page.locator("#stats-cards .card").count() >= 4, "Stats incomplete"
assert page.locator("#pending-operations").is_visible(), "Pending ops missing"
assert page.locator("#stacks-by-host").is_visible(), "Stacks by host missing"
assert page.locator("#sidebar-stacks").is_visible(), "Sidebar missing"
def test_sidebar_persists_after_navigation_and_back(self, page: Page, server_url: str) -> None:
"""Sidebar content persists through navigation cycle."""
page.goto(server_url)
page.wait_for_selector("#sidebar-stacks", timeout=TIMEOUT)
# Remember sidebar state
initial_count = page.locator("#sidebar-stacks li").count()
assert initial_count == 6
# Navigate away
page.locator("#sidebar-stacks a", has_text="plex").click()
page.wait_for_url("**/stack/plex", timeout=TIMEOUT)
# Sidebar should still be there with same content
assert page.locator("#sidebar-stacks").is_visible()
assert page.locator("#sidebar-stacks li").count() == initial_count
# Navigate back
page.go_back()
page.wait_for_url(server_url, timeout=TIMEOUT)
# Sidebar still intact
assert page.locator("#sidebar-stacks").is_visible()
assert page.locator("#sidebar-stacks li").count() == initial_count
def test_dashboard_sections_persist_after_save(self, page: Page, server_url: str) -> None:
"""Dashboard sections remain after save triggers cf:refresh event."""
page.goto(server_url)
page.wait_for_selector("#sidebar-stacks", timeout=TIMEOUT)
# Capture initial state - all must be visible
assert page.locator("#stats-cards").is_visible()
assert page.locator("#pending-operations").is_visible()
assert page.locator("#stacks-by-host").is_visible()
# Trigger save (which dispatches cf:refresh)
page.locator("#save-config-btn").click()
page.wait_for_function(
"document.querySelector('#save-config-btn')?.textContent?.includes('Saved')",
timeout=TIMEOUT,
)
# Wait for refresh requests to complete
page.wait_for_load_state("networkidle")
# All sections must still be visible
assert page.locator("#stats-cards").is_visible(), "Stats disappeared after save"
assert page.locator("#pending-operations").is_visible(), "Pending disappeared"
assert page.locator("#stacks-by-host").is_visible(), "Stacks disappeared"
assert page.locator("#sidebar-stacks").is_visible(), "Sidebar disappeared"
def test_filter_state_not_affected_by_other_htmx_requests(
self, page: Page, server_url: str
) -> None:
"""Sidebar filter state persists during other HTMX activity."""
page.goto(server_url)
page.wait_for_selector("#sidebar-stacks", timeout=TIMEOUT)
# Apply a filter
filter_input = page.locator("#sidebar-filter")
filter_input.fill("plex")
filter_input.dispatch_event("keyup")
# Verify filter is applied
assert page.locator("#sidebar-stacks li:not([hidden])").count() == 1
# Trigger a save (causes cf:refresh on multiple elements)
page.locator("#save-config-btn").click()
page.wait_for_timeout(1000)
# Filter input should still have our text
# (Note: sidebar reloads so filter clears - this tests the sidebar reload works)
page.wait_for_selector("#sidebar-stacks", timeout=TIMEOUT)
assert page.locator("#sidebar-stacks").is_visible()
def test_main_content_not_affected_by_sidebar_refresh(
self, page: Page, server_url: str
) -> None:
"""Main content area stays intact when sidebar refreshes."""
page.goto(server_url)
page.wait_for_selector("#sidebar-stacks", timeout=TIMEOUT)
# Get main content text
main_content = page.locator("#main-content")
initial_text = main_content.inner_text()
assert "Compose Farm" in initial_text
# Trigger cf:refresh (which refreshes sidebar)
page.evaluate("document.body.dispatchEvent(new CustomEvent('cf:refresh'))")
page.wait_for_timeout(500)
# Main content should be unchanged (same page, just refreshed partials)
assert "Compose Farm" in main_content.inner_text()
assert page.locator("#stats-cards").is_visible()
def test_no_duplicate_elements_after_multiple_refreshes(
self, page: Page, server_url: str
) -> None:
"""Multiple refresh cycles don't create duplicate elements."""
page.goto(server_url)
page.wait_for_selector("#sidebar-stacks", timeout=TIMEOUT)
# Count initial elements
initial_stat_count = page.locator("#stats-cards .card").count()
initial_stack_count = page.locator("#sidebar-stacks li").count()
# Trigger multiple refreshes
for _ in range(3):
page.evaluate("document.body.dispatchEvent(new CustomEvent('cf:refresh'))")
page.wait_for_timeout(300)
page.wait_for_load_state("networkidle")
# Counts should be same (no duplicates created)
assert page.locator("#stats-cards .card").count() == initial_stat_count
assert page.locator("#sidebar-stacks li").count() == initial_stack_count
class TestConsolePage:
"""Test console page functionality."""
def test_console_page_renders(self, page: Page, server_url: str) -> None:
"""Console page renders with all required elements."""
page.goto(f"{server_url}/console")
# Wait for page to load
page.wait_for_selector("#console-host-select", timeout=TIMEOUT)
# Verify host selector exists
host_select = page.locator("#console-host-select")
assert host_select.is_visible()
# Verify Connect button exists
connect_btn = page.locator("#console-connect-btn")
assert connect_btn.is_visible()
assert "Connect" in connect_btn.inner_text()
# Verify terminal container exists
terminal_container = page.locator("#console-terminal")
assert terminal_container.is_visible()
# Verify editor container exists
editor_container = page.locator("#console-editor")
assert editor_container.is_visible()
# Verify file path input exists
file_input = page.locator("#console-file-path")
assert file_input.is_visible()
# Verify save button exists
save_btn = page.locator("#console-save-btn")
assert save_btn.is_visible()
def test_console_host_selector_shows_all_hosts(self, page: Page, server_url: str) -> None:
"""Host selector dropdown contains all configured hosts."""
page.goto(f"{server_url}/console")
page.wait_for_selector("#console-host-select", timeout=TIMEOUT)
# Get all options from the dropdown
options = page.locator("#console-host-select option")
assert options.count() == 2 # server-1 and server-2 from test config
# Verify both hosts are present
option_texts = [options.nth(i).inner_text() for i in range(options.count())]
assert any("server-1" in text for text in option_texts)
assert any("server-2" in text for text in option_texts)
def test_console_connect_shows_status(self, page: Page, server_url: str) -> None:
"""Console shows connection status when attempting to connect."""
page.goto(f"{server_url}/console")
page.wait_for_selector("#console-host-select", timeout=TIMEOUT)
# Wait for terminal to initialize (triggers auto-connect)
page.wait_for_function("typeof Terminal !== 'undefined'", timeout=TIMEOUT)
page.wait_for_selector("#console-terminal .xterm", timeout=TIMEOUT)
# Status element should exist and have some text
# (may show "Connected", "Connecting...", or "Disconnected" depending on WebSocket)
status = page.locator("#console-status")
assert status.is_visible()
status_text = status.inner_text()
# Should show some connection-related status
assert any(s in status_text for s in ["Connect", "Disconnect", "server-"])
def test_console_connect_creates_terminal_element(self, page: Page, server_url: str) -> None:
"""Connecting to a host creates xterm terminal elements.
The console page auto-connects to the first host on load,
which creates the xterm.js terminal inside the container.
"""
page.goto(f"{server_url}/console")
page.wait_for_selector("#console-terminal", timeout=TIMEOUT)
# Wait for xterm.js to load from CDN
page.wait_for_function("typeof Terminal !== 'undefined'", timeout=TIMEOUT)
# The console page auto-connects, which creates the terminal.
# Wait for xterm to initialize (creates .xterm class)
page.wait_for_selector("#console-terminal .xterm", timeout=TIMEOUT)
# Verify xterm elements are present
xterm_container = page.locator("#console-terminal .xterm")
assert xterm_container.is_visible()
# Verify xterm screen is created (the actual terminal display)
xterm_screen = page.locator("#console-terminal .xterm-screen")
assert xterm_screen.is_visible()
def test_console_editor_initializes(self, page: Page, server_url: str) -> None:
"""Monaco editor initializes on the console page."""
page.goto(f"{server_url}/console")
page.wait_for_selector("#console-editor", timeout=TIMEOUT)
# Wait for Monaco to load from CDN
page.wait_for_function("typeof monaco !== 'undefined'", timeout=TIMEOUT)
# Monaco creates elements inside the container
page.wait_for_selector("#console-editor .monaco-editor", timeout=TIMEOUT)
# Verify Monaco editor is present
monaco_editor = page.locator("#console-editor .monaco-editor")
assert monaco_editor.is_visible()
def test_console_load_file_calls_api(self, page: Page, server_url: str) -> None:
"""Clicking Open button calls the file API with correct parameters."""
page.goto(f"{server_url}/console")
page.wait_for_selector("#console-file-path", timeout=TIMEOUT)
# Wait for terminal to connect (sets currentHost)
page.wait_for_function("typeof Terminal !== 'undefined'", timeout=TIMEOUT)
page.wait_for_selector("#console-terminal .xterm", timeout=TIMEOUT)
# Track API calls
api_calls: list[str] = []
def handle_route(route: Route) -> None:
api_calls.append(route.request.url)
route.fulfill(
status=200,
content_type="application/json",
body='{"success": true, "content": "test file content"}',
)
page.route("**/api/console/file*", handle_route)
# Enter a file path and click Open
file_input = page.locator("#console-file-path")
file_input.fill("/tmp/test.yaml")
page.locator("button", has_text="Open").click()
# Wait for API call
page.wait_for_timeout(500)
# Verify API was called with correct parameters
assert len(api_calls) >= 1
assert "/api/console/file" in api_calls[0]
assert "path=" in api_calls[0]
assert "host=" in api_calls[0]
def test_console_load_file_shows_content(self, page: Page, server_url: str) -> None:
"""Loading a file displays its content in the Monaco editor."""
page.goto(f"{server_url}/console")
page.wait_for_selector("#console-file-path", timeout=TIMEOUT)
# Wait for terminal to connect and Monaco to load
page.wait_for_function("typeof Terminal !== 'undefined'", timeout=TIMEOUT)
page.wait_for_selector("#console-terminal .xterm", timeout=TIMEOUT)
page.wait_for_function("typeof monaco !== 'undefined'", timeout=TIMEOUT)
page.wait_for_selector("#console-editor .monaco-editor", timeout=TIMEOUT)
# Mock file API to return specific content
test_content = "services:\\n nginx:\\n image: nginx:latest"
def handle_route(route: Route) -> None:
route.fulfill(
status=200,
content_type="application/json",
body=f'{{"success": true, "content": "{test_content}"}}',
)
page.route("**/api/console/file*", handle_route)
# Load file
file_input = page.locator("#console-file-path")
file_input.fill("/tmp/compose.yaml")
page.locator("button", has_text="Open").click()
# Wait for content to be loaded into editor
page.wait_for_function(
"window.consoleEditor && window.consoleEditor.getValue().includes('nginx')",
timeout=TIMEOUT,
)
def test_console_load_file_updates_status(self, page: Page, server_url: str) -> None:
"""Loading a file updates the editor status to show the file path."""
page.goto(f"{server_url}/console")
page.wait_for_selector("#console-file-path", timeout=TIMEOUT)
# Wait for terminal and Monaco
page.wait_for_function("typeof Terminal !== 'undefined'", timeout=TIMEOUT)
page.wait_for_selector("#console-terminal .xterm", timeout=TIMEOUT)
page.wait_for_function("typeof monaco !== 'undefined'", timeout=TIMEOUT)
page.wait_for_selector("#console-editor .monaco-editor", timeout=TIMEOUT)
# Mock file API
page.route(
"**/api/console/file*",
lambda route: route.fulfill(
status=200,
content_type="application/json",
body='{"success": true, "content": "test"}',
),
)
# Load file
file_input = page.locator("#console-file-path")
file_input.fill("/tmp/test.yaml")
page.locator("button", has_text="Open").click()
# Wait for status to show "Loaded:"
page.wait_for_function(
"document.getElementById('editor-status')?.textContent?.includes('Loaded')",
timeout=TIMEOUT,
)
# Verify status shows the file path
status = page.locator("#editor-status").inner_text()
assert "Loaded" in status
assert "test.yaml" in status
def test_console_save_file_calls_api(self, page: Page, server_url: str) -> None:
"""Clicking Save button calls the file API with PUT method."""
page.goto(f"{server_url}/console")
page.wait_for_selector("#console-file-path", timeout=TIMEOUT)
# Wait for terminal to connect and Monaco to load
page.wait_for_function("typeof Terminal !== 'undefined'", timeout=TIMEOUT)
page.wait_for_selector("#console-terminal .xterm", timeout=TIMEOUT)
page.wait_for_function("typeof monaco !== 'undefined'", timeout=TIMEOUT)
page.wait_for_selector("#console-editor .monaco-editor", timeout=TIMEOUT)
# Track API calls
api_calls: list[tuple[str, str]] = [] # (method, url)
def handle_load_route(route: Route) -> None:
api_calls.append((route.request.method, route.request.url))
route.fulfill(
status=200,
content_type="application/json",
body='{"success": true, "content": "original content"}',
)
def handle_save_route(route: Route) -> None:
api_calls.append((route.request.method, route.request.url))
route.fulfill(
status=200,
content_type="application/json",
body='{"success": true}',
)
page.route(
"**/api/console/file*",
lambda route: (
handle_save_route(route)
if route.request.method == "PUT"
else handle_load_route(route)
),
)
# Load a file first (required before save works)
file_input = page.locator("#console-file-path")
file_input.fill("/tmp/test.yaml")
page.locator("button", has_text="Open").click()
page.wait_for_timeout(500)
# Clear api_calls to track only the save
api_calls.clear()
# Click Save button
page.locator("#console-save-btn").click()
page.wait_for_timeout(500)
# Verify PUT request was made
assert len(api_calls) >= 1
method, url = api_calls[0]
assert method == "PUT"
assert "/api/console/file" in url
def test_console_save_file_updates_status(self, page: Page, server_url: str) -> None:
"""Saving a file updates the editor status to show 'Saved'."""
page.goto(f"{server_url}/console")
page.wait_for_selector("#console-file-path", timeout=TIMEOUT)
# Wait for terminal and Monaco
page.wait_for_function("typeof Terminal !== 'undefined'", timeout=TIMEOUT)
page.wait_for_selector("#console-terminal .xterm", timeout=TIMEOUT)
page.wait_for_function("typeof monaco !== 'undefined'", timeout=TIMEOUT)
page.wait_for_selector("#console-editor .monaco-editor", timeout=TIMEOUT)
# Mock file API for both load and save
def handle_route(route: Route) -> None:
if route.request.method == "PUT":
route.fulfill(
status=200,
content_type="application/json",
body='{"success": true}',
)
else:
route.fulfill(
status=200,
content_type="application/json",
body='{"success": true, "content": "test"}',
)
page.route("**/api/console/file*", handle_route)
# Load file first
file_input = page.locator("#console-file-path")
file_input.fill("/tmp/test.yaml")
page.locator("button", has_text="Open").click()
page.wait_for_function(
"document.getElementById('editor-status')?.textContent?.includes('Loaded')",
timeout=TIMEOUT,
)
# Save file
page.locator("#console-save-btn").click()
# Wait for status to show "Saved:"
page.wait_for_function(
"document.getElementById('editor-status')?.textContent?.includes('Saved')",
timeout=TIMEOUT,
)
# Verify status shows saved
status = page.locator("#editor-status").inner_text()
assert "Saved" in status
class TestTerminalStreaming:
"""Test terminal streaming functionality for action commands."""
def test_terminal_stores_task_in_localstorage(self, page: Page, server_url: str) -> None:
"""Action response stores task ID in localStorage for reconnection."""
page.goto(server_url)
page.wait_for_selector("#sidebar-stacks", timeout=TIMEOUT)
# Mock Apply API to return a task ID
page.route(
"**/api/apply",
lambda route: route.fulfill(
status=200,
content_type="application/json",
body='{"task_id": "test-task-123", "stack": null, "command": "apply"}',
),
)
# Clear localStorage first
page.evaluate("localStorage.clear()")
# Click Apply
page.locator("button", has_text="Apply").click()
# Poll for localStorage to be set (more reliable than fixed wait)
page.wait_for_function(
"localStorage.getItem('cf_task:/') === 'test-task-123'",
timeout=TIMEOUT,
)
def test_terminal_reconnects_from_localstorage(self, page: Page, server_url: str) -> None:
"""Terminal attempts to reconnect to task stored in localStorage.
Tests that when a page loads with an active task in localStorage,
it expands the terminal and attempts to reconnect.
"""
# First, set up a task in localStorage before navigating
page.goto(server_url)
page.wait_for_selector("#sidebar-stacks", timeout=TIMEOUT)
# Store a task ID in localStorage
page.evaluate("localStorage.setItem('cf_task:/', 'reconnect-test-123')")
# Navigate away and back (or reload) to trigger reconnect
page.goto(f"{server_url}/console")
page.wait_for_selector("#console-terminal", timeout=TIMEOUT)
# Navigate back to dashboard
page.goto(server_url)
page.wait_for_selector("#sidebar-stacks", timeout=TIMEOUT)
# Wait for xterm to load (reconnect uses whenXtermReady)
page.wait_for_function("typeof Terminal !== 'undefined'", timeout=TIMEOUT)
# Terminal should be expanded because tryReconnectToTask runs
page.wait_for_function(
"document.getElementById('terminal-toggle')?.checked === true",
timeout=TIMEOUT,
)
def test_action_triggers_terminal_websocket_connection(
self, page: Page, server_url: str
) -> None:
"""Action response with task_id triggers WebSocket connection to correct path."""
page.goto(server_url)
page.wait_for_selector("#sidebar-stacks", timeout=TIMEOUT)
# Track WebSocket connections
ws_urls: list[str] = []
def handle_ws(ws: WebSocket) -> None:
ws_urls.append(ws.url)
page.on("websocket", handle_ws)
# Mock Apply API to return a task ID
page.route(
"**/api/apply",
lambda route: route.fulfill(
status=200,
content_type="application/json",
body='{"task_id": "ws-test-456", "stack": null, "command": "apply"}',
),
)
# Wait for xterm to load
page.wait_for_function("typeof Terminal !== 'undefined'", timeout=TIMEOUT)
# Click Apply
page.locator("button", has_text="Apply").click()
# Wait for WebSocket connection
page.wait_for_timeout(1000)
# Verify WebSocket connected to correct path
assert len(ws_urls) >= 1
assert any("/ws/terminal/ws-test-456" in url for url in ws_urls)
def test_terminal_displays_connected_message(self, page: Page, server_url: str) -> None:
"""Terminal shows [Connected] message after WebSocket opens."""
page.goto(server_url)
page.wait_for_selector("#sidebar-stacks", timeout=TIMEOUT)
# Mock Apply API to return a task ID
page.route(
"**/api/apply",
lambda route: route.fulfill(
status=200,
content_type="application/json",
body='{"task_id": "connected-test", "stack": null, "command": "apply"}',
),
)
# Wait for xterm to load
page.wait_for_function("typeof Terminal !== 'undefined'", timeout=TIMEOUT)
# Click Apply to trigger terminal
page.locator("button", has_text="Apply").click()
# Wait for terminal to be created and WebSocket to connect
page.wait_for_selector("#terminal-output .xterm", timeout=TIMEOUT)
# Wait for [Connected] message to appear in terminal
# xterm.js renders content into .xterm-rows
page.wait_for_function(
"""() => {
const viewport = document.querySelector('#terminal-output .xterm-rows');
return viewport && viewport.textContent.includes('Connected');
}""",
timeout=TIMEOUT,
)
class TestExecTerminal:
"""Test exec terminal functionality for container shells."""
def test_stack_page_has_exec_terminal_container(self, page: Page, server_url: str) -> None:
"""Service page has exec terminal container (initially hidden)."""
page.goto(server_url)
page.wait_for_selector("#sidebar-stacks a", timeout=TIMEOUT)
# Navigate to plex stack
page.locator("#sidebar-stacks a", has_text="plex").click()
page.wait_for_url("**/stack/plex", timeout=TIMEOUT)
# Exec terminal container should exist but be hidden
exec_container = page.locator("#exec-terminal-container")
assert exec_container.count() == 1
assert "hidden" in (exec_container.get_attribute("class") or "")
# The inner terminal div should also exist
exec_terminal = page.locator("#exec-terminal")
assert exec_terminal.count() == 1
def test_exec_terminal_connects_websocket(self, page: Page, server_url: str) -> None:
"""Clicking Shell button triggers WebSocket to exec endpoint."""
page.goto(server_url)
page.wait_for_selector("#sidebar-stacks a", timeout=TIMEOUT)
# Navigate to plex stack
page.locator("#sidebar-stacks a", has_text="plex").click()
page.wait_for_url("**/stack/plex", timeout=TIMEOUT)
# Mock containers API to return a container
page.route(
"**/api/stack/plex/containers*",
lambda route: route.fulfill(
status=200,
content_type="text/html",
body="""
<div class="flex items-center gap-2 p-2 bg-base-200 rounded">
<span class="status status-success"></span>
<code class="text-sm flex-1">plex-container</code>
<button class="btn btn-sm btn-outline"
onclick="initExecTerminal('plex', 'plex-container', 'server-1')">
Shell
</button>
</div>
""",
),
)
# Reload to get mocked containers
page.reload()
page.wait_for_selector("#sidebar-stacks", timeout=TIMEOUT)
# Track WebSocket connections
ws_urls: list[str] = []
def handle_ws(ws: WebSocket) -> None:
ws_urls.append(ws.url)
page.on("websocket", handle_ws)
# Wait for xterm to load
page.wait_for_function("typeof Terminal !== 'undefined'", timeout=TIMEOUT)
# Click Shell button
page.locator("button", has_text="Shell").click()
# Wait for WebSocket connection
page.wait_for_timeout(1000)
# Verify WebSocket connected to exec endpoint
assert len(ws_urls) >= 1
assert any("/ws/exec/plex/plex-container/server-1" in url for url in ws_urls)
# Exec terminal container should now be visible
exec_container = page.locator("#exec-terminal-container")
assert "hidden" not in (exec_container.get_attribute("class") or "")
class TestServicePagePalette:
"""Test command palette behavior on stack pages."""
def test_stack_page_palette_has_action_commands(self, page: Page, server_url: str) -> None:
"""Command palette on stack page shows stack-specific actions."""
page.goto(server_url)
page.wait_for_selector("#sidebar-stacks a", timeout=TIMEOUT)
# Navigate to plex stack
page.locator("#sidebar-stacks a", has_text="plex").click()
page.wait_for_url("**/stack/plex", timeout=TIMEOUT)
# Open command palette
page.keyboard.press("Control+k")
page.wait_for_selector("#cmd-palette[open]", timeout=SHORT_TIMEOUT)
# Verify stack-specific action commands are visible
cmd_list = page.locator("#cmd-list").inner_text()
assert "Up" in cmd_list
assert "Down" in cmd_list
assert "Restart" in cmd_list
assert "Pull" in cmd_list
assert "Update" in cmd_list
assert "Logs" in cmd_list
def test_palette_action_triggers_stack_api(self, page: Page, server_url: str) -> None:
"""Selecting action from palette triggers correct stack API."""
page.goto(server_url)
page.wait_for_selector("#sidebar-stacks a", timeout=TIMEOUT)
# Navigate to plex stack
page.locator("#sidebar-stacks a", has_text="plex").click()
page.wait_for_url("**/stack/plex", timeout=TIMEOUT)
# Track API calls
api_calls: list[str] = []
def handle_route(route: Route) -> None:
api_calls.append(route.request.url)
route.fulfill(
status=200,
content_type="application/json",
body='{"task_id": "palette-test", "stack": "plex", "command": "up"}',
)
page.route("**/api/stack/plex/up", handle_route)
# Open command palette
page.keyboard.press("Control+k")
page.wait_for_selector("#cmd-palette[open]", timeout=SHORT_TIMEOUT)
# Filter to "Up" and execute
page.locator("#cmd-input").fill("Up")
page.keyboard.press("Enter")
# Wait for API call
page.wait_for_timeout(500)
# Verify correct API was called
assert len(api_calls) >= 1
assert "/api/stack/plex/up" in api_calls[0]
def test_palette_apply_from_stack_page(self, page: Page, server_url: str) -> None:
"""Selecting Apply from stack page palette navigates to dashboard and triggers API."""
page.goto(server_url)
page.wait_for_selector("#sidebar-stacks a", timeout=TIMEOUT)
# Navigate to plex stack
page.locator("#sidebar-stacks a", has_text="plex").click()
page.wait_for_url("**/stack/plex", timeout=TIMEOUT)
# Track API calls
api_calls: list[str] = []
def handle_route(route: Route) -> None:
api_calls.append(route.request.url)
route.fulfill(
status=200,
content_type="application/json",
body='{"task_id": "apply-test", "stack": null, "command": "apply"}',
)
page.route("**/api/apply", handle_route)
# Open command palette
page.keyboard.press("Control+k")
page.wait_for_selector("#cmd-palette[open]", timeout=SHORT_TIMEOUT)
# Filter to "Apply" and execute
page.locator("#cmd-input").fill("Apply")
page.keyboard.press("Enter")
# Wait for navigation to dashboard and API call
page.wait_for_url(server_url, timeout=TIMEOUT)
page.wait_for_timeout(500)
# Verify Apply API was called
assert len(api_calls) >= 1
assert "/api/apply" in api_calls[0]
def test_palette_shows_service_commands(self, page: Page, server_url: str) -> None:
"""Command palette on stack page shows service-specific commands."""
page.goto(server_url)
page.wait_for_selector("#sidebar-stacks a", timeout=TIMEOUT)
# Navigate to plex stack (has plex and redis services)
page.locator("#sidebar-stacks a", has_text="plex").click()
page.wait_for_url("**/stack/plex", timeout=TIMEOUT)
# Open command palette
page.keyboard.press("Control+k")
page.wait_for_selector("#cmd-palette[open]", timeout=SHORT_TIMEOUT)
# Filter to service commands
page.locator("#cmd-input").fill("Restart:")
cmd_list = page.locator("#cmd-list").inner_text()
# Should show restart commands for both services
assert "Restart: plex-server" in cmd_list
assert "Restart: redis" in cmd_list
def test_palette_service_commands_for_all_actions(self, page: Page, server_url: str) -> None:
"""Service commands include all expected actions (restart, pull, logs, stop, up)."""
page.goto(server_url)
page.wait_for_selector("#sidebar-stacks a", timeout=TIMEOUT)
# Navigate to plex stack
page.locator("#sidebar-stacks a", has_text="plex").click()
page.wait_for_url("**/stack/plex", timeout=TIMEOUT)
# Open command palette
page.keyboard.press("Control+k")
page.wait_for_selector("#cmd-palette[open]", timeout=SHORT_TIMEOUT)
# Check all service action types exist for the plex-server service
actions = ["Restart", "Pull", "Logs", "Stop", "Up"]
for action in actions:
page.locator("#cmd-input").fill(f"{action}: plex-server")
cmd_list = page.locator("#cmd-list").inner_text()
assert f"{action}: plex-server" in cmd_list, f"Missing {action}: plex-server command"
def test_palette_service_command_triggers_api(self, page: Page, server_url: str) -> None:
"""Selecting service command triggers correct service API endpoint."""
page.goto(server_url)
page.wait_for_selector("#sidebar-stacks a", timeout=TIMEOUT)
# Navigate to plex stack
page.locator("#sidebar-stacks a", has_text="plex").click()
page.wait_for_url("**/stack/plex", timeout=TIMEOUT)
# Track API calls
api_calls: list[str] = []
def handle_route(route: Route) -> None:
api_calls.append(route.request.url)
route.fulfill(
status=200,
content_type="application/json",
body='{"task_id": "svc-test", "stack": "plex", "service": "redis", "command": "restart"}',
)
page.route("**/api/stack/plex/service/redis/restart", handle_route)
# Open command palette
page.keyboard.press("Control+k")
page.wait_for_selector("#cmd-palette[open]", timeout=SHORT_TIMEOUT)
# Filter to Restart:redis and execute
page.locator("#cmd-input").fill("Restart: redis")
page.keyboard.press("Enter")
# Wait for API call
page.wait_for_timeout(500)
# Verify correct service API was called
assert len(api_calls) >= 1
assert "/api/stack/plex/service/redis/restart" in api_calls[0]
def test_palette_service_commands_have_teal_indicator(
self, page: Page, server_url: str
) -> None:
"""Service commands display with teal color indicator."""
page.goto(server_url)
page.wait_for_selector("#sidebar-stacks a", timeout=TIMEOUT)
# Navigate to plex stack
page.locator("#sidebar-stacks a", has_text="plex").click()
page.wait_for_url("**/stack/plex", timeout=TIMEOUT)
# Open command palette
page.keyboard.press("Control+k")
page.wait_for_selector("#cmd-palette[open]", timeout=SHORT_TIMEOUT)
# Filter to a service command
page.locator("#cmd-input").fill("Restart: plex-server")
# Get the command element and check its border color
cmd_item = page.locator("#cmd-list a", has_text="Restart: plex-server").first
style = cmd_item.get_attribute("style") or ""
# Service commands should have teal color (#14b8a6)
assert "#14b8a6" in style, f"Expected teal border color, got style: {style}"
def test_single_service_stack_shows_service_commands(self, page: Page, server_url: str) -> None:
"""Single-service stacks also show service commands."""
page.goto(server_url)
page.wait_for_selector("#sidebar-stacks li", timeout=TIMEOUT)
# Navigate to redis stack (has only redis service)
redis_link = page.locator("#sidebar-stacks a", has_text="redis")
redis_link.wait_for(timeout=TIMEOUT)
redis_link.click()
page.wait_for_url("**/stack/redis", timeout=TIMEOUT)
# Open command palette
page.keyboard.press("Control+k")
page.wait_for_selector("#cmd-palette[open]", timeout=SHORT_TIMEOUT)
# Filter to service commands
page.locator("#cmd-input").fill("Restart:")
cmd_list = page.locator("#cmd-list").inner_text()
# Should show restart command for redis service
assert "Restart: redis" in cmd_list
def test_palette_filter_without_colon(self, page: Page, server_url: str) -> None:
"""Filter matches service commands without colon (e.g., 'Up redis' matches 'Up: redis')."""
page.goto(server_url)
page.wait_for_selector("#sidebar-stacks a", timeout=TIMEOUT)
# Navigate to plex stack
page.locator("#sidebar-stacks a", has_text="plex").click()
page.wait_for_url("**/stack/plex", timeout=TIMEOUT)
# Open command palette
page.keyboard.press("Control+k")
page.wait_for_selector("#cmd-palette[open]", timeout=SHORT_TIMEOUT)
# Type "Restart redis" without colon
page.locator("#cmd-input").fill("Restart redis")
cmd_list = page.locator("#cmd-list").inner_text()
# Should still match "Restart: redis"
assert "Restart: redis" in cmd_list
def test_palette_fuzzy_filter_partial_words(self, page: Page, server_url: str) -> None:
"""Filter matches with partial words (e.g., 'rest red' matches 'Restart: redis')."""
page.goto(server_url)
page.wait_for_selector("#sidebar-stacks a", timeout=TIMEOUT)
# Navigate to plex stack
page.locator("#sidebar-stacks a", has_text="plex").click()
page.wait_for_url("**/stack/plex", timeout=TIMEOUT)
# Open command palette
page.keyboard.press("Control+k")
page.wait_for_selector("#cmd-palette[open]", timeout=SHORT_TIMEOUT)
# Type partial words "rest red"
page.locator("#cmd-input").fill("rest red")
cmd_list = page.locator("#cmd-list").inner_text()
# Should match "Restart: redis"
assert "Restart: redis" in cmd_list
def test_palette_fuzzy_filter_any_order(self, page: Page, server_url: str) -> None:
"""Filter matches words in any order (e.g., 'redis rest' matches 'Restart: redis')."""
page.goto(server_url)
page.wait_for_selector("#sidebar-stacks a", timeout=TIMEOUT)
# Navigate to plex stack
page.locator("#sidebar-stacks a", has_text="plex").click()
page.wait_for_url("**/stack/plex", timeout=TIMEOUT)
# Open command palette
page.keyboard.press("Control+k")
page.wait_for_selector("#cmd-palette[open]", timeout=SHORT_TIMEOUT)
# Type words in reverse order "redis rest"
page.locator("#cmd-input").fill("redis rest")
cmd_list = page.locator("#cmd-list").inner_text()
# Should match "Restart: redis"
assert "Restart: redis" in cmd_list
def test_palette_filter_without_colon_triggers_api(self, page: Page, server_url: str) -> None:
"""Service command filtered without colon still triggers correct API."""
page.goto(server_url)
page.wait_for_selector("#sidebar-stacks a", timeout=TIMEOUT)
# Navigate to plex stack
page.locator("#sidebar-stacks a", has_text="plex").click()
page.wait_for_url("**/stack/plex", timeout=TIMEOUT)
# Track API calls
api_calls: list[str] = []
def handle_route(route: Route) -> None:
api_calls.append(route.request.url)
route.fulfill(
status=200,
content_type="application/json",
body='{"task_id": "test", "stack": "plex", "service": "redis", "command": "pull"}',
)
page.route("**/api/stack/plex/service/redis/pull", handle_route)
# Open command palette
page.keyboard.press("Control+k")
page.wait_for_selector("#cmd-palette[open]", timeout=SHORT_TIMEOUT)
# Type "Pull redis" without colon and execute
page.locator("#cmd-input").fill("Pull redis")
page.keyboard.press("Enter")
# Wait for API call
page.wait_for_timeout(500)
# Verify correct service API was called
assert len(api_calls) >= 1
assert "/api/stack/plex/service/redis/pull" in api_calls[0]
def test_palette_hyphenated_service_name(self, page: Page, server_url: str) -> None:
"""Filter matches hyphenated service names by second word (e.g., 'server' matches 'plex-server')."""
page.goto(server_url)
page.wait_for_selector("#sidebar-stacks a", timeout=TIMEOUT)
# Navigate to plex stack (has plex-server service)
page.locator("#sidebar-stacks a", has_text="plex").click()
page.wait_for_url("**/stack/plex", timeout=TIMEOUT)
# Open command palette
page.keyboard.press("Control+k")
page.wait_for_selector("#cmd-palette[open]", timeout=SHORT_TIMEOUT)
# Type just "server" - should match "plex-server" because hyphen splits words
page.locator("#cmd-input").fill("Restart server")
cmd_list = page.locator("#cmd-list").inner_text()
# Should match "Restart: plex-server"
assert "Restart: plex-server" in cmd_list
# Also verify "rest plex" matches via the first part of hyphenated name
page.locator("#cmd-input").fill("rest plex")
cmd_list = page.locator("#cmd-list").inner_text()
assert "Restart: plex-server" in cmd_list
def test_shell_commands_in_palette(self, page: Page, server_url: str) -> None:
"""Command palette includes Shell commands for each service."""
page.goto(server_url)
page.wait_for_selector("#sidebar-stacks a", timeout=TIMEOUT)
# Navigate to plex stack
page.locator("#sidebar-stacks a", has_text="plex").click()
page.wait_for_url("**/stack/plex", timeout=TIMEOUT)
# Open command palette
page.keyboard.press("Control+k")
page.wait_for_selector("#cmd-palette[open]", timeout=SHORT_TIMEOUT)
# Filter to shell commands
page.locator("#cmd-input").fill("Shell:")
cmd_list = page.locator("#cmd-list").inner_text()
# Should have Shell commands for plex-server and redis services
assert "Shell: plex-server" in cmd_list
assert "Shell: redis" in cmd_list
def test_shell_command_fuzzy_match(self, page: Page, server_url: str) -> None:
"""Shell commands can be found with fuzzy search."""
page.goto(server_url)
page.wait_for_selector("#sidebar-stacks a", timeout=TIMEOUT)
# Navigate to plex stack
page.locator("#sidebar-stacks a", has_text="plex").click()
page.wait_for_url("**/stack/plex", timeout=TIMEOUT)
# Open command palette
page.keyboard.press("Control+k")
page.wait_for_selector("#cmd-palette[open]", timeout=SHORT_TIMEOUT)
# Type "shell redis" without colon
page.locator("#cmd-input").fill("shell redis")
cmd_list = page.locator("#cmd-list").inner_text()
# Should match "Shell: redis"
assert "Shell: redis" in cmd_list
class TestThemeSwitcher:
"""Test theme switcher via command palette."""
@staticmethod
def _open_theme_palette(page: Page) -> None:
"""Open the command palette with theme filter by clicking the theme button."""
page.locator("#theme-btn").click()
page.wait_for_selector("#cmd-palette[open]", timeout=SHORT_TIMEOUT)
@staticmethod
def _select_theme(page: Page, theme: str) -> None:
"""Select a theme from the command palette."""
# Filter to the specific theme
page.locator("#cmd-input").fill(f"theme: {theme}")
page.keyboard.press("Enter")
def test_theme_button_exists(self, page: Page, server_url: str) -> None:
"""Theme button exists in sidebar header."""
page.goto(server_url)
page.wait_for_selector("#sidebar-stacks", timeout=TIMEOUT)
# Theme button should exist
assert page.locator("#theme-btn").count() == 1
def test_theme_button_opens_palette_with_filter(self, page: Page, server_url: str) -> None:
"""Clicking theme button opens command palette pre-filtered to themes."""
page.goto(server_url)
page.wait_for_selector("#sidebar-stacks", timeout=TIMEOUT)
self._open_theme_palette(page)
# Input should have "theme:" filter
assert page.locator("#cmd-input").input_value() == "theme:"
# Should show theme options
cmd_list = page.locator("#cmd-list").inner_text()
assert "light" in cmd_list
assert "dark" in cmd_list
def test_clicking_theme_changes_html_data_theme(self, page: Page, server_url: str) -> None:
"""Selecting a theme changes the data-theme attribute on <html>."""
page.goto(server_url)
page.wait_for_selector("#sidebar-stacks", timeout=TIMEOUT)
# Get initial theme
initial_theme = page.locator("html").get_attribute("data-theme")
# Select a different theme
target_theme = "cupcake" if initial_theme != "cupcake" else "dracula"
self._open_theme_palette(page)
self._select_theme(page, target_theme)
# Verify the html element's data-theme changed
new_theme = page.locator("html").get_attribute("data-theme")
assert new_theme == target_theme, f"Expected {target_theme}, got {new_theme}"
def test_theme_persists_in_localstorage(self, page: Page, server_url: str) -> None:
"""Selected theme is saved to localStorage."""
page.goto(server_url)
page.wait_for_selector("#sidebar-stacks", timeout=TIMEOUT)
# Select synthwave theme
self._open_theme_palette(page)
self._select_theme(page, "synthwave")
# Check localStorage
stored = page.evaluate("localStorage.getItem('cf_theme')")
assert stored == "synthwave"
def test_theme_restored_on_page_load(self, page: Page, server_url: str) -> None:
"""Theme is restored from localStorage on page load."""
page.goto(server_url)
page.wait_for_selector("#sidebar-stacks", timeout=TIMEOUT)
# Set theme
self._open_theme_palette(page)
self._select_theme(page, "retro")
# Reload page
page.reload()
page.wait_for_selector("#sidebar-stacks", timeout=TIMEOUT)
# Theme should be restored
theme = page.locator("html").get_attribute("data-theme")
assert theme == "retro"
def test_theme_can_be_changed_multiple_times(self, page: Page, server_url: str) -> None:
"""Theme can be changed multiple times in a session."""
page.goto(server_url)
page.wait_for_selector("#sidebar-stacks", timeout=TIMEOUT)
themes_to_test = ["light", "dark", "nord", "sunset"]
for theme in themes_to_test:
self._open_theme_palette(page)
self._select_theme(page, theme)
current = page.locator("html").get_attribute("data-theme")
assert current == theme, f"Failed to switch to {theme}, got {current}"
def test_themes_available_in_regular_palette(self, page: Page, server_url: str) -> None:
"""Themes are also available when opening regular command palette."""
page.goto(server_url)
page.wait_for_selector("#sidebar-stacks", timeout=TIMEOUT)
# Open with Cmd+K
page.keyboard.press("Control+k")
page.wait_for_selector("#cmd-palette[open]", timeout=SHORT_TIMEOUT)
# Type theme filter
page.locator("#cmd-input").fill("theme:")
# Should show theme options
cmd_list = page.locator("#cmd-list").inner_text()
assert "theme: light" in cmd_list
assert "theme: dark" in cmd_list
def test_theme_filter_without_colon(self, page: Page, server_url: str) -> None:
"""Filter matches theme commands without colon (e.g., 'theme dark' matches 'theme: dark')."""
page.goto(server_url)
page.wait_for_selector("#sidebar-stacks", timeout=TIMEOUT)
# Open with Cmd+K
page.keyboard.press("Control+k")
page.wait_for_selector("#cmd-palette[open]", timeout=SHORT_TIMEOUT)
# Type "theme dark" without colon
page.locator("#cmd-input").fill("theme dark")
# Should show theme: dark option
cmd_list = page.locator("#cmd-list").inner_text()
assert "theme: dark" in cmd_list
def test_theme_command_opens_theme_picker(self, page: Page, server_url: str) -> None:
"""Selecting 'Theme' command reopens palette with theme filter."""
page.goto(server_url)
page.wait_for_selector("#sidebar-stacks", timeout=TIMEOUT)
# Open palette and select Theme command
page.keyboard.press("Control+k")
page.wait_for_selector("#cmd-palette[open]", timeout=SHORT_TIMEOUT)
# Filter to Theme command and select it
page.locator("#cmd-input").fill("Theme")
page.keyboard.press("Enter")
# Palette should reopen with "theme:" filter
page.wait_for_selector("#cmd-palette[open]", timeout=SHORT_TIMEOUT)
assert page.locator("#cmd-input").input_value() == "theme:"
# Should show theme options
cmd_list = page.locator("#cmd-list").inner_text()
assert "theme: light" in cmd_list
def test_current_theme_is_preselected(self, page: Page, server_url: str) -> None:
"""Opening theme picker pre-selects the current theme."""
page.goto(server_url)
page.wait_for_selector("#sidebar-stacks", timeout=TIMEOUT)
# Set a specific theme first
self._open_theme_palette(page)
self._select_theme(page, "dracula")
# Reopen theme palette
self._open_theme_palette(page)
# The selected item (with bg-base-300) should be dracula
selected_item = page.locator("#cmd-list a.bg-base-300")
assert selected_item.count() == 1
assert "dracula" in selected_item.inner_text()
def test_theme_shows_color_swatches(self, page: Page, server_url: str) -> None:
"""Theme commands show color preview swatches."""
page.goto(server_url)
page.wait_for_selector("#sidebar-stacks", timeout=TIMEOUT)
self._open_theme_palette(page)
# Theme items should have color swatches with data-theme attribute
light_swatch = page.locator("#cmd-list [data-theme='light']")
assert light_swatch.count() >= 1
# Swatch should contain bg-primary, bg-secondary, etc.
swatch_html = light_swatch.first.inner_html()
assert "bg-primary" in swatch_html
assert "bg-secondary" in swatch_html
def test_theme_preview_on_arrow_navigation(self, page: Page, server_url: str) -> None:
"""Arrow key navigation previews themes without persisting."""
page.goto(server_url)
page.wait_for_selector("#sidebar-stacks", timeout=TIMEOUT)
# Set initial theme
self._open_theme_palette(page)
self._select_theme(page, "dark")
# Reopen palette
self._open_theme_palette(page)
# Navigate to a different theme with arrow keys
page.locator("#cmd-input").fill("theme: cupcake")
page.keyboard.press("ArrowDown") # Select cupcake
# Theme should be previewed
current = page.locator("html").get_attribute("data-theme")
assert current == "cupcake"
# But localStorage should still have original
stored = page.evaluate("localStorage.getItem('cf_theme')")
assert stored == "dark"
# Press Escape to cancel
page.keyboard.press("Escape")
# Theme should be restored
current = page.locator("html").get_attribute("data-theme")
assert current == "dark"
def test_theme_preview_restored_on_escape(self, page: Page, server_url: str) -> None:
"""Pressing Escape restores original theme."""
page.goto(server_url)
page.wait_for_selector("#sidebar-stacks", timeout=TIMEOUT)
# Set initial theme
self._open_theme_palette(page)
self._select_theme(page, "nord")
initial = page.locator("html").get_attribute("data-theme")
assert initial == "nord"
# Open palette and navigate to different theme
self._open_theme_palette(page)
page.locator("#cmd-input").fill("theme: synthwave")
# Preview should change
page.wait_for_function(
"document.documentElement.getAttribute('data-theme') === 'synthwave'"
)
# Press Escape
page.keyboard.press("Escape")
# Should restore original
restored = page.locator("html").get_attribute("data-theme")
assert restored == "nord"
def test_theme_restored_after_non_theme_command(self, page: Page, server_url: str) -> None:
"""Theme restores when executing non-theme command after preview."""
page.goto(server_url)
page.wait_for_selector("#sidebar-stacks", timeout=TIMEOUT)
# Set initial theme to dark
self._open_theme_palette(page)
self._select_theme(page, "dark")
# Open palette, navigate to a theme to preview it
self._open_theme_palette(page)
page.locator("#cmd-input").fill("theme: cupcake")
page.wait_for_timeout(200)
# cupcake should be previewed
assert page.locator("html").get_attribute("data-theme") == "cupcake"
# Now filter to Dashboard (non-theme command)
page.locator("#cmd-input").fill("Dashboard")
page.wait_for_timeout(200)
# Theme should restore since Dashboard has no themeId
current = page.locator("html").get_attribute("data-theme")
assert current == "dark", f"Expected dark after filtering to Dashboard, got {current}"
# Execute Dashboard
page.keyboard.press("Enter")
page.wait_for_selector("#cmd-palette:not([open])", timeout=SHORT_TIMEOUT)
# Theme should still be dark
final = page.locator("html").get_attribute("data-theme")
assert final == "dark", f"Expected dark after executing Dashboard, got {final}"
class TestTerminalNavigationIsolation:
"""Test that terminal connections are properly isolated per page.
Regression tests for a bug where navigating away from a stack page via
command palette would cause the new page to reconnect to the old page's
terminal task because history.pushState hadn't updated the URL yet when
tryReconnectToTask() ran.
"""
def test_stack_terminal_not_reconnected_on_dashboard(self, page: Page, server_url: str) -> None:
"""Terminal started on stack page should NOT reconnect when navigating to dashboard.
Bug scenario:
1. On /stack/plex, click Update → terminal connects, task stored at cf_task:/stack/plex
2. Navigate to dashboard via command palette
3. Dashboard loads, htmx:afterSwap fires
4. tryReconnectToTask() runs but window.location.pathname is still /stack/plex
(because pushState hasn't run yet)
5. Bug: Dashboard reconnects to plex's terminal task
Expected: Dashboard should NOT have any task_id in its localStorage key (cf_task:/)
"""
page.goto(server_url)
page.wait_for_selector("#sidebar-stacks a", timeout=TIMEOUT)
# Navigate to plex stack
page.locator("#sidebar-stacks a", has_text="plex").click()
page.wait_for_url("**/stack/plex", timeout=TIMEOUT)
# Clear any existing task state
page.evaluate("localStorage.clear()")
# Track WebSocket connections to see which terminals are opened
ws_urls: list[str] = []
def handle_ws(ws: WebSocket) -> None:
ws_urls.append(ws.url)
page.on("websocket", handle_ws)
# Mock Update API to return a task ID
page.route(
"**/api/stack/plex/update",
lambda route: route.fulfill(
status=200,
content_type="application/json",
body='{"task_id": "plex-update-task-123", "stack": "plex", "command": "update"}',
),
)
# Wait for xterm to load
page.wait_for_function("typeof Terminal !== 'undefined'", timeout=TIMEOUT)
# Click Update button on plex stack page
page.locator("button", has_text="Update").click()
# Wait for terminal to connect
page.wait_for_selector("#terminal-output .xterm", timeout=TIMEOUT)
page.wait_for_timeout(500)
# Verify task was stored for /stack/plex
plex_task = page.evaluate("localStorage.getItem('cf_task:/stack/plex')")
assert plex_task == "plex-update-task-123", (
f"Expected task stored at /stack/plex, got {plex_task}"
)
# Dashboard should have NO task yet
dashboard_task_before = page.evaluate("localStorage.getItem('cf_task:/')")
assert dashboard_task_before is None, (
f"Dashboard should have no task before navigation, got {dashboard_task_before}"
)
# Count current WebSocket connections
ws_count_before = len(ws_urls)
# Navigate to dashboard via command palette (this triggers the bug)
page.keyboard.press("Control+k")
page.wait_for_selector("#cmd-palette[open]", timeout=SHORT_TIMEOUT)
page.locator("#cmd-input").fill("Dashboard")
page.keyboard.press("Enter")
# Wait for navigation to complete
page.wait_for_url(server_url, timeout=TIMEOUT)
page.wait_for_selector("#stats-cards", timeout=TIMEOUT)
# Give time for any erroneous reconnection attempts
page.wait_for_timeout(1000)
# CRITICAL ASSERTION: Dashboard should NOT have a task in localStorage
dashboard_task_after = page.evaluate("localStorage.getItem('cf_task:/')")
assert dashboard_task_after is None, (
f"Bug detected: Dashboard incorrectly has task '{dashboard_task_after}' in localStorage. "
"This means tryReconnectToTask() ran before pushState updated the URL."
)
# CRITICAL ASSERTION: No new WebSocket should have been opened for the plex task
# after navigating to dashboard
new_ws_urls = ws_urls[ws_count_before:]
plex_reconnect_attempts = [url for url in new_ws_urls if "plex-update-task-123" in url]
assert len(plex_reconnect_attempts) == 0, (
f"Bug detected: Dashboard attempted to reconnect to plex task. "
f"New WebSocket URLs after navigation: {new_ws_urls}"
)
def test_dashboard_terminal_not_shown_after_stack_navigation(
self, page: Page, server_url: str
) -> None:
"""Dashboard's terminal should remain collapsed when navigating away and back.
This tests that navigating from dashboard → stack → dashboard doesn't
cause the terminal to expand unexpectedly.
"""
page.goto(server_url)
page.wait_for_selector("#sidebar-stacks", timeout=TIMEOUT)
# Terminal should be collapsed on dashboard
terminal_toggle = page.locator("#terminal-toggle")
assert not terminal_toggle.is_checked(), "Terminal should be collapsed initially"
# Navigate to a stack
page.locator("#sidebar-stacks a", has_text="plex").click()
page.wait_for_url("**/stack/plex", timeout=TIMEOUT)
# Navigate back to dashboard via command palette
page.keyboard.press("Control+k")
page.wait_for_selector("#cmd-palette[open]", timeout=SHORT_TIMEOUT)
page.locator("#cmd-input").fill("Dashboard")
page.keyboard.press("Enter")
page.wait_for_url(server_url, timeout=TIMEOUT)
# Terminal should still be collapsed (no task to reconnect to)
terminal_toggle = page.locator("#terminal-toggle")
assert not terminal_toggle.is_checked(), "Terminal should remain collapsed after navigation"
class TestContainersPagePause:
"""Test containers page auto-refresh pause mechanism.
The containers page auto-refreshes every 3 seconds. When a user opens
an action dropdown, refresh should pause to prevent the dropdown from
closing unexpectedly.
"""
# Mock HTML for container rows with action dropdowns
MOCK_ROWS_HTML = """
<tr>
<td>1</td>
<td data-sort="plex"><a href="/stack/plex" class="link">plex</a></td>
<td data-sort="server">server</td>
<td><div class="dropdown dropdown-end">
<label tabindex="0" class="btn btn-circle btn-ghost btn-xs"><svg class="h-4 w-4"></svg></label>
<ul tabindex="0" class="dropdown-content menu menu-sm bg-base-200 rounded-box shadow-lg w-36 z-50 p-2">
<li><a hx-post="/api/stack/plex/restart">Restart</a></li>
</ul>
</div></td>
<td data-sort="nas"><span class="badge">nas</span></td>
<td data-sort="nginx:latest"><code>nginx:latest</code></td>
<td data-sort="running"><span class="badge badge-success">running</span></td>
<td data-sort="3600">1 hour</td>
<td data-sort="5"><progress class="progress" value="5" max="100"></progress><span>5%</span></td>
<td data-sort="104857600"><progress class="progress" value="10" max="100"></progress><span>100MB</span></td>
<td data-sort="1000">↓1KB ↑1KB</td>
</tr>
<tr>
<td>2</td>
<td data-sort="redis"><a href="/stack/redis" class="link">redis</a></td>
<td data-sort="redis">redis</td>
<td><div class="dropdown dropdown-end">
<label tabindex="0" class="btn btn-circle btn-ghost btn-xs"><svg class="h-4 w-4"></svg></label>
<ul tabindex="0" class="dropdown-content menu menu-sm bg-base-200 rounded-box shadow-lg w-36 z-50 p-2">
<li><a hx-post="/api/stack/redis/restart">Restart</a></li>
</ul>
</div></td>
<td data-sort="nas"><span class="badge">nas</span></td>
<td data-sort="redis:7"><code>redis:7</code></td>
<td data-sort="running"><span class="badge badge-success">running</span></td>
<td data-sort="7200">2 hours</td>
<td data-sort="1"><progress class="progress" value="1" max="100"></progress><span>1%</span></td>
<td data-sort="52428800"><progress class="progress" value="5" max="100"></progress><span>50MB</span></td>
<td data-sort="500">↓500B ↑500B</td>
</tr>
"""
def test_dropdown_pauses_refresh(self, page: Page, server_url: str) -> None:
"""Opening action dropdown pauses auto-refresh.
Bug: focusin event triggers pause, but focusout fires shortly after
when focus moves within the dropdown, causing refresh to resume
while dropdown is still visually open.
"""
# Mock container rows and update checks
page.route(
"**/api/containers/rows/*",
lambda route: route.fulfill(
status=200,
content_type="text/html",
body=self.MOCK_ROWS_HTML,
),
)
page.route(
"**/api/containers/check-updates",
lambda route: route.fulfill(
status=200,
content_type="application/json",
body='{"results": []}',
),
)
page.goto(f"{server_url}/live-stats")
# Wait for container rows to load
page.wait_for_function(
"document.querySelectorAll('#container-rows tr:not(.loading-row)').length > 0",
timeout=TIMEOUT,
)
# Wait for timer to start
page.wait_for_function(
"document.getElementById('refresh-timer')?.textContent?.includes('')",
timeout=TIMEOUT,
)
# Click on a dropdown to open it
dropdown_label = page.locator(".dropdown label").first
dropdown_label.click()
# Wait a moment for focusin to trigger
page.wait_for_timeout(200)
# Verify pause is engaged
timer_text = page.locator("#refresh-timer").inner_text()
assert timer_text == "❚❚", (
f"Refresh should be paused after clicking dropdown. timer='{timer_text}'"
)
assert "❚❚" in timer_text, f"Timer should show pause icon, got '{timer_text}'"
def test_refresh_stays_paused_while_dropdown_open(self, page: Page, server_url: str) -> None:
"""Refresh remains paused for duration dropdown is open (>5s refresh interval).
This is the critical test for the pause bug: refresh should stay paused
for longer than the 3-second refresh interval while dropdown is open.
"""
# Mock container rows and update checks
page.route(
"**/api/containers/rows/*",
lambda route: route.fulfill(
status=200,
content_type="text/html",
body=self.MOCK_ROWS_HTML,
),
)
page.route(
"**/api/containers/check-updates",
lambda route: route.fulfill(
status=200,
content_type="application/json",
body='{"results": []}',
),
)
page.goto(f"{server_url}/live-stats")
# Wait for container rows to load
page.wait_for_function(
"document.querySelectorAll('#container-rows tr:not(.loading-row)').length > 0",
timeout=TIMEOUT,
)
# Wait for timer to start
page.wait_for_function(
"document.getElementById('refresh-timer')?.textContent?.includes('')",
timeout=TIMEOUT,
)
# Record a marker in the first row to detect if refresh happened
page.evaluate("""
const firstRow = document.querySelector('#container-rows tr');
if (firstRow) firstRow.dataset.testMarker = 'original';
""")
# Click dropdown to pause
dropdown_label = page.locator(".dropdown label").first
dropdown_label.click()
page.wait_for_timeout(200)
# Confirm paused
assert page.locator("#refresh-timer").inner_text() == "❚❚"
# Wait longer than the 5-second refresh interval
page.wait_for_timeout(6000)
# Check if still paused
timer_text = page.locator("#refresh-timer").inner_text()
# Check if the row was replaced (marker would be gone)
marker = page.evaluate("""
document.querySelector('#container-rows tr')?.dataset?.testMarker
""")
assert timer_text == "❚❚", f"Refresh should still be paused after 6s. timer='{timer_text}'"
assert marker == "original", (
"Table was refreshed while dropdown was open - pause mechanism failed"
)
def test_refresh_resumes_after_dropdown_closes(self, page: Page, server_url: str) -> None:
"""Refresh resumes after dropdown is closed."""
# Mock container rows and update checks
page.route(
"**/api/containers/rows/*",
lambda route: route.fulfill(
status=200,
content_type="text/html",
body=self.MOCK_ROWS_HTML,
),
)
page.route(
"**/api/containers/check-updates",
lambda route: route.fulfill(
status=200,
content_type="application/json",
body='{"results": []}',
),
)
page.goto(f"{server_url}/live-stats")
# Wait for container rows to load
page.wait_for_function(
"document.querySelectorAll('#container-rows tr:not(.loading-row)').length > 0",
timeout=TIMEOUT,
)
# Wait for timer to start
page.wait_for_function(
"document.getElementById('refresh-timer')?.textContent?.includes('')",
timeout=TIMEOUT,
)
# Click dropdown to pause
dropdown_label = page.locator(".dropdown label").first
dropdown_label.click()
page.wait_for_timeout(200)
assert page.locator("#refresh-timer").inner_text() == "❚❚"
# Close dropdown by pressing Escape or clicking elsewhere
page.keyboard.press("Escape")
page.wait_for_timeout(300) # Wait for focusout timeout (150ms) + buffer
# Verify refresh resumed
timer_text = page.locator("#refresh-timer").inner_text()
assert timer_text != "❚❚", (
f"Refresh should resume after closing dropdown. timer='{timer_text}'"
)
assert "" in timer_text, f"Timer should show countdown, got '{timer_text}'"