mirror of
https://github.com/basnijholt/compose-farm.git
synced 2026-02-03 14:13:26 +00:00
2566 lines
98 KiB
Python
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}'"
|