feat(web): Auto-refresh dashboard and clean up HTMX inheritance (#49)

This commit is contained in:
Bas Nijholt
2025-12-18 20:07:31 -08:00
committed by GitHub
parent cd25a1914c
commit 1fa17b4e07
8 changed files with 195 additions and 23 deletions

View File

@@ -195,6 +195,27 @@ class TestHTMXSidebarLoading:
assert "radarr" 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-services", timeout=5000)
# 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 services."""
page.goto(server_url)
@@ -736,3 +757,146 @@ class TestKeyboardShortcuts:
"document.querySelector('#save-config-btn')?.textContent?.includes('Saved')",
timeout=5000,
)
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-services", timeout=5000)
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("#services-by-host").is_visible(), "Services by host missing"
assert page.locator("#sidebar-services").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-services", timeout=5000)
# Remember sidebar state
initial_count = page.locator("#sidebar-services li").count()
assert initial_count == 4
# Navigate away
page.locator("#sidebar-services a", has_text="plex").click()
page.wait_for_url("**/service/plex", timeout=5000)
# Sidebar should still be there with same content
assert page.locator("#sidebar-services").is_visible()
assert page.locator("#sidebar-services li").count() == initial_count
# Navigate back
page.go_back()
page.wait_for_url(server_url, timeout=5000)
# Sidebar still intact
assert page.locator("#sidebar-services").is_visible()
assert page.locator("#sidebar-services 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-services", timeout=5000)
# Capture initial state - all must be visible
assert page.locator("#stats-cards").is_visible()
assert page.locator("#pending-operations").is_visible()
assert page.locator("#services-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=5000,
)
# 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("#services-by-host").is_visible(), "Services disappeared"
assert page.locator("#sidebar-services").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-services", timeout=5000)
# 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-services 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-services", timeout=5000)
assert page.locator("#sidebar-services").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-services", timeout=5000)
# 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-services", timeout=5000)
# Count initial elements
initial_stat_count = page.locator("#stats-cards .card").count()
initial_service_count = page.locator("#sidebar-services 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-services li").count() == initial_service_count