d7d07ec07e
Bug: On systems without systemd (Unraid OS 7.2.4, BSD, macOS containers,
and other non-systemd Linux hosts), running `muxplex upgrade` or
`muxplex update` crashed immediately with:
FileNotFoundError: [Errno 2] No such file or directory: 'systemctl'
The check ran unconditionally before any install step, so the upgrade
aborted without even attempting to fetch the new version. Introduced
in v0.6.0.
Fix: Add a module-level `_have_systemctl() -> bool` helper to cli.py
(and service.py) that gates every systemd-specific operation behind
`shutil.which("systemctl") is not None`.
Call sites guarded in cli.py (upgrade() function):
1. systemctl --user is-active muxplex (stop-before-upgrade check)
2. systemctl --user stop muxplex (pre-install service stop)
3. Service file regeneration step (service_install() call)
4. systemctl --user is-enabled muxplex (post-install restart check)
5. systemctl --user daemon-reload (post-install daemon reload)
6. systemctl --user start muxplex (post-install service start)
Behaviour on no-systemd systems:
- Skips the is-active check (treats as unmanaged; prints skip note).
- Skips the stop step.
- Still performs the uv/pip install.
- Skips service-file regeneration (prints skip note).
- Skips the daemon-reload / start steps.
- Prints: '! systemd not detected — restart muxplex manually to pick
up the new version' with the running PID if pgrep finds one.
- Still runs `muxplex doctor` for verification (no systemd required).
doctor() change:
- On non-darwin platforms with no systemctl, now prints:
'! Service: systemd not available on this platform'
instead of silently doing nothing or crashing.
service.py change:
- Public API functions (service_install, service_uninstall,
service_start, service_stop, service_restart, service_status,
service_logs) now check _have_systemctl() on the Linux path.
- When absent, print a clear, friendly error pointing the user to
`muxplex serve` instead of letting subprocess raise FileNotFoundError.
Tests added (muxplex/tests/test_cli.py, +8 tests):
- test_have_systemctl_helper_exists
- test_have_systemctl_returns_bool
- test_upgrade_no_systemctl_runs_to_completion (regression)
- test_upgrade_no_systemctl_prints_skip_note
- test_upgrade_no_systemctl_prints_manual_restart_note
- test_upgrade_with_systemctl_runs_systemd_commands
- test_doctor_no_systemctl_shows_graceful_message
- test_doctor_no_systemctl_does_not_crash
485 lines
20 KiB
Python
485 lines
20 KiB
Python
"""Tests for 6 UX fixes: manage-view rename/delete, unified views submenu,
|
|
stable checkbox list, dead code cleanup.
|
|
"""
|
|
|
|
import pathlib
|
|
import re
|
|
|
|
JS_PATH = pathlib.Path(__file__).parent.parent / "frontend" / "app.js"
|
|
CSS_PATH = pathlib.Path(__file__).parent.parent / "frontend" / "style.css"
|
|
|
|
_JS: str = JS_PATH.read_text()
|
|
_CSS: str = CSS_PATH.read_text()
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
# Fix 1: Manage View panel — rename click handler
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
def test_open_manage_view_panel_adds_rename_click_handler() -> None:
|
|
"""openManageViewPanel must attach a click handler on #manage-view-name."""
|
|
match = re.search(
|
|
r"function openManageViewPanel\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\n// )",
|
|
_JS,
|
|
re.DOTALL,
|
|
)
|
|
assert match, "openManageViewPanel function not found"
|
|
body = match.group(1)
|
|
# Must reference manage-view-name and attach a click handler
|
|
assert "manage-view-name" in body, (
|
|
"openManageViewPanel must reference manage-view-name"
|
|
)
|
|
assert "onclick" in body or "addEventListener" in body or "click" in body.lower(), (
|
|
"openManageViewPanel must attach a click handler on manage-view-name"
|
|
)
|
|
|
|
|
|
def test_manage_view_rename_creates_input_element() -> None:
|
|
"""The rename handler on manage-view-name must create an <input> element."""
|
|
# The click handler should create an input element for in-place renaming
|
|
assert "manage-view-panel__name-input" in _JS or (
|
|
"type" in _JS and "text" in _JS and "manage-view-name" in _JS
|
|
), "Rename handler must create an input element for inline editing"
|
|
|
|
|
|
def test_manage_view_rename_validates_non_empty() -> None:
|
|
"""The rename handler must validate the name is non-empty before saving."""
|
|
# Check that the rename logic in openManageViewPanel has non-empty validation
|
|
# The validation logic should be present somewhere in the file
|
|
assert "trim()" in _JS, "Rename must trim and validate non-empty input"
|
|
|
|
|
|
def test_manage_view_rename_patches_settings() -> None:
|
|
"""The rename flow must PATCH /api/settings with updated view name."""
|
|
# The rename action must call PATCH /api/settings
|
|
assert "PATCH" in _JS and "/api/settings" in _JS, "Rename must PATCH /api/settings"
|
|
|
|
|
|
def test_manage_view_rename_handles_escape() -> None:
|
|
"""The rename input must revert on Escape key."""
|
|
match = re.search(
|
|
r"function openManageViewPanel\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\n// )",
|
|
_JS,
|
|
re.DOTALL,
|
|
)
|
|
assert match, "openManageViewPanel function not found"
|
|
body = match.group(1)
|
|
assert "Escape" in body, (
|
|
"openManageViewPanel rename handler must handle Escape key to revert"
|
|
)
|
|
|
|
|
|
def test_manage_view_rename_handles_enter() -> None:
|
|
"""The rename input must commit on Enter key."""
|
|
match = re.search(
|
|
r"function openManageViewPanel\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\n// )",
|
|
_JS,
|
|
re.DOTALL,
|
|
)
|
|
assert match, "openManageViewPanel function not found"
|
|
body = match.group(1)
|
|
assert "Enter" in body, (
|
|
"openManageViewPanel rename handler must handle Enter key to commit"
|
|
)
|
|
|
|
|
|
def test_manage_view_rename_updates_active_view() -> None:
|
|
"""After rename, _activeView must be updated to the new name."""
|
|
match = re.search(
|
|
r"function openManageViewPanel\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\n// )",
|
|
_JS,
|
|
re.DOTALL,
|
|
)
|
|
assert match, "openManageViewPanel function not found"
|
|
body = match.group(1)
|
|
assert "_activeView" in body, (
|
|
"openManageViewPanel rename handler must update _activeView to new name"
|
|
)
|
|
|
|
|
|
def test_manage_view_name_input_css_exists() -> None:
|
|
""".manage-view-panel__name-input CSS rule must exist for the rename input."""
|
|
assert ".manage-view-panel__name-input" in _CSS, (
|
|
".manage-view-panel__name-input CSS rule must exist for the rename input"
|
|
)
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
# Fix 2: Manage View panel — delete button in header
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
def test_open_manage_view_panel_has_delete_button() -> None:
|
|
"""openManageViewPanel must render a delete/trash button in the header."""
|
|
match = re.search(
|
|
r"function openManageViewPanel\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\n// )",
|
|
_JS,
|
|
re.DOTALL,
|
|
)
|
|
assert match, "openManageViewPanel function not found"
|
|
body = match.group(1)
|
|
# Must have some delete/trash button or delete action
|
|
has_delete = (
|
|
"delete" in body.lower()
|
|
or "trash" in body.lower()
|
|
or "manage-view-delete" in body
|
|
or 'data-action="delete"' in body
|
|
)
|
|
assert has_delete, "openManageViewPanel must render a delete button in the header"
|
|
|
|
|
|
def test_manage_view_delete_shows_confirmation() -> None:
|
|
"""The delete button must show inline confirmation before deleting."""
|
|
match = re.search(
|
|
r"function openManageViewPanel\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\n// )",
|
|
_JS,
|
|
re.DOTALL,
|
|
)
|
|
assert match, "openManageViewPanel function not found"
|
|
body = match.group(1)
|
|
# Must have Yes/No or confirm-delete pattern
|
|
has_confirm = (
|
|
"confirm" in body.lower()
|
|
or "Yes" in body
|
|
or "confirm-delete" in body
|
|
or "Delete this view" in body
|
|
)
|
|
assert has_confirm, (
|
|
"Manage View delete button must show inline confirmation (Yes/No) before deleting"
|
|
)
|
|
|
|
|
|
def test_manage_view_delete_removes_view_from_settings() -> None:
|
|
"""Delete confirmation must PATCH /api/settings to remove the view."""
|
|
# The delete flow must involve PATCH /api/settings with updated views
|
|
# This is already present for the delete in settings tab, so check it's
|
|
# also connected from openManageViewPanel
|
|
match = re.search(
|
|
r"function openManageViewPanel\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\n// )",
|
|
_JS,
|
|
re.DOTALL,
|
|
)
|
|
assert match, "openManageViewPanel function not found"
|
|
body = match.group(1)
|
|
# openManageViewPanel must either patch directly or call a helper that does
|
|
assert (
|
|
"api(" in body or "PATCH" in body or "_saveViews" in body or "splice" in body
|
|
), "Manage View delete must call PATCH /api/settings to remove the view"
|
|
|
|
|
|
def test_manage_view_delete_switches_to_all() -> None:
|
|
"""After deleting the view, must switch to 'all' view."""
|
|
match = re.search(
|
|
r"function openManageViewPanel\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\n// )",
|
|
_JS,
|
|
re.DOTALL,
|
|
)
|
|
assert match, "openManageViewPanel function not found"
|
|
body = match.group(1)
|
|
assert "switchView" in body or "'all'" in body, (
|
|
"Manage View delete must switch to 'all' view after deletion"
|
|
)
|
|
|
|
|
|
def test_manage_view_delete_shows_toast() -> None:
|
|
"""After confirming delete, must show a toast notification."""
|
|
match = re.search(
|
|
r"function openManageViewPanel\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\n// )",
|
|
_JS,
|
|
re.DOTALL,
|
|
)
|
|
assert match, "openManageViewPanel function not found"
|
|
body = match.group(1)
|
|
assert "showToast" in body, "Manage View delete must call showToast after deleting"
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
# Fix 3: Remove rename affordance from settings tab
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
def test_views_settings_name_no_cursor_pointer_css() -> None:
|
|
""".views-settings-name must NOT have cursor: pointer (rename affordance removed)."""
|
|
match = re.search(
|
|
r"\.views-settings-name\s*\{([^}]*)\}",
|
|
_CSS,
|
|
re.DOTALL,
|
|
)
|
|
if match:
|
|
body = match.group(1)
|
|
assert "cursor: pointer" not in body, (
|
|
".views-settings-name must not have cursor: pointer — rename is only via Manage panel"
|
|
)
|
|
|
|
|
|
def test_views_settings_name_no_hover_underline_css() -> None:
|
|
""".views-settings-name:hover must NOT have text-decoration: underline (rename affordance removed)."""
|
|
# Either the hover rule doesn't exist or it doesn't have underline
|
|
hover_match = re.search(
|
|
r"\.views-settings-name:hover\s*\{([^}]*)\}",
|
|
_CSS,
|
|
re.DOTALL,
|
|
)
|
|
if hover_match:
|
|
body = hover_match.group(1)
|
|
assert "text-decoration: underline" not in body, (
|
|
".views-settings-name:hover must not have text-decoration: underline"
|
|
)
|
|
|
|
|
|
def test_views_settings_rename_input_css_removed() -> None:
|
|
""".views-settings-rename-input CSS must be removed (dead class for settings-tab rename)."""
|
|
assert ".views-settings-rename-input" not in _CSS, (
|
|
".views-settings-rename-input CSS class must be removed — rename is no longer in settings tab"
|
|
)
|
|
|
|
|
|
def test_render_views_settings_tab_no_rename_handler() -> None:
|
|
"""renderViewsSettingsTab must NOT attach a click-to-rename handler on name span."""
|
|
match = re.search(
|
|
r"function renderViewsSettingsTab\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\n// |\n/\*)",
|
|
_JS,
|
|
re.DOTALL,
|
|
)
|
|
assert match, "renderViewsSettingsTab function not found"
|
|
body = match.group(1)
|
|
# Must not have inline rename input creation
|
|
assert "views-settings-rename-input" not in body, (
|
|
"renderViewsSettingsTab must not create rename input — rename is only via Manage panel"
|
|
)
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
# Fix 4: Merge Add/Remove into unified Views submenu
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
def test_flyout_menu_map_user_has_no_remove_from_view() -> None:
|
|
"""FLYOUT_MENU_MAP 'user' entry must NOT have a separate 'remove-from-view' item."""
|
|
# Find the FLYOUT_MENU_MAP definition
|
|
match = re.search(
|
|
r"const FLYOUT_MENU_MAP\s*=\s*\{(.*?)\};",
|
|
_JS,
|
|
re.DOTALL,
|
|
)
|
|
assert match, "FLYOUT_MENU_MAP not found"
|
|
map_body = match.group(1)
|
|
# Find the 'user' section
|
|
user_match = re.search(
|
|
r"'user'\s*:\s*\[(.*?)\]",
|
|
map_body,
|
|
re.DOTALL,
|
|
)
|
|
assert user_match, "'user' entry in FLYOUT_MENU_MAP not found"
|
|
user_items = user_match.group(1)
|
|
assert "remove-from-view" not in user_items, (
|
|
"FLYOUT_MENU_MAP 'user' must not have separate 'remove-from-view' item — "
|
|
"use unified Views submenu instead"
|
|
)
|
|
|
|
|
|
def test_flyout_menu_map_all_has_views_submenu() -> None:
|
|
"""FLYOUT_MENU_MAP 'all' must have a Views submenu item."""
|
|
match = re.search(
|
|
r"const FLYOUT_MENU_MAP\s*=\s*\{(.*?)\};",
|
|
_JS,
|
|
re.DOTALL,
|
|
)
|
|
assert match, "FLYOUT_MENU_MAP not found"
|
|
map_body = match.group(1)
|
|
all_match = re.search(
|
|
r"'all'\s*:\s*\[(.*?)\]",
|
|
map_body,
|
|
re.DOTALL,
|
|
)
|
|
assert all_match, "'all' entry in FLYOUT_MENU_MAP not found"
|
|
all_items = all_match.group(1)
|
|
# Should still have add-to-view or views-submenu action
|
|
assert "add-to-view" in all_items or "views-submenu" in all_items, (
|
|
"FLYOUT_MENU_MAP 'all' must have views submenu item"
|
|
)
|
|
|
|
|
|
def test_flyout_submenu_stays_open_after_toggle() -> None:
|
|
"""_openFlyoutSubmenu must NOT close the submenu after a checkbox toggle."""
|
|
match = re.search(
|
|
r"function _openFlyoutSubmenu\s*\(.*?\)\s*\{(.*?)(?=\nfunction |\n// )",
|
|
_JS,
|
|
re.DOTALL,
|
|
)
|
|
assert match, "_openFlyoutSubmenu function not found"
|
|
body = match.group(1)
|
|
# The submenu click handler should NOT call closeFlyoutMenu() on successful toggle
|
|
# It should only call closeFlyoutMenu on 'new-view' action
|
|
# Check that there's no unconditional closeFlyoutMenu after a toggle
|
|
# The best we can check is that the submenu stays alive (no remove() on the submenu)
|
|
# We verify the API PATCH callback doesn't close the menu
|
|
assert "_flyoutSubmenuEl" in body or "submenu" in body.lower(), (
|
|
"_openFlyoutSubmenu must maintain submenu reference for staying open"
|
|
)
|
|
|
|
|
|
def test_flyout_submenu_shows_checkmarks_for_current_view_sessions() -> None:
|
|
"""_openFlyoutSubmenu must show checkmarks for views the session is already in."""
|
|
match = re.search(
|
|
r"function _openFlyoutSubmenu\s*\(.*?\)\s*\{(.*?)(?=\nfunction |\n// )",
|
|
_JS,
|
|
re.DOTALL,
|
|
)
|
|
assert match, "_openFlyoutSubmenu function not found"
|
|
body = match.group(1)
|
|
# Must show a checkmark (✓) for views the session is in
|
|
assert "\\u2713" in body or "✓" in body or "isIn" in body, (
|
|
"_openFlyoutSubmenu must show checkmarks for views the session is already in"
|
|
)
|
|
|
|
|
|
def test_flyout_submenu_shows_all_user_views() -> None:
|
|
"""_openFlyoutSubmenu must show ALL user views (not skip the current active view)."""
|
|
match = re.search(
|
|
r"function _openFlyoutSubmenu\s*\(.*?\)\s*\{(.*?)(?=\nfunction |\n// )",
|
|
_JS,
|
|
re.DOTALL,
|
|
)
|
|
assert match, "_openFlyoutSubmenu function not found"
|
|
body = match.group(1)
|
|
# The old code skipped the active view: "if (isInUserView && v.name === _activeView) continue;"
|
|
# Now it should NOT skip the current view — all views should be shown
|
|
assert "isInUserView" not in body or "continue" not in body, (
|
|
"_openFlyoutSubmenu must show ALL user views including the currently active one"
|
|
)
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
# Fix 5: Fix checkbox re-render layout thrash
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
def test_manage_view_checkbox_no_full_rerender_on_success() -> None:
|
|
"""renderManageViewList onChange handler must NOT call renderManageViewList() on success."""
|
|
match = re.search(
|
|
r"function renderManageViewList\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\n// |\n/\*)",
|
|
_JS,
|
|
re.DOTALL,
|
|
)
|
|
assert match, "renderManageViewList function not found"
|
|
body = match.group(1)
|
|
|
|
# Find the listEl.onchange handler
|
|
onchange_match = re.search(
|
|
r"listEl\.onchange\s*=\s*function\s*\(e\)\s*\{(.*?)\}\s*;",
|
|
body,
|
|
re.DOTALL,
|
|
)
|
|
assert onchange_match, "listEl.onchange handler not found in renderManageViewList"
|
|
onchange_body = onchange_match.group(1)
|
|
|
|
# The .then() callback must NOT call renderManageViewList()
|
|
then_match = re.search(
|
|
r"\.then\s*\(function\s*\(\s*\)\s*\{(.*?)\}\s*\)",
|
|
onchange_body,
|
|
re.DOTALL,
|
|
)
|
|
if then_match:
|
|
then_body = then_match.group(1)
|
|
assert "renderManageViewList()" not in then_body, (
|
|
"renderManageViewList onChange .then() must NOT call renderManageViewList() "
|
|
"— this causes layout thrash and item position jumps"
|
|
)
|
|
|
|
|
|
def test_manage_view_checkbox_updates_summary_without_rerender() -> None:
|
|
"""renderManageViewList onChange handler must update summary count in-place."""
|
|
match = re.search(
|
|
r"function renderManageViewList\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\n// |\n/\*)",
|
|
_JS,
|
|
re.DOTALL,
|
|
)
|
|
assert match, "renderManageViewList function not found"
|
|
body = match.group(1)
|
|
|
|
# Find the listEl.onchange handler
|
|
onchange_match = re.search(
|
|
r"listEl\.onchange\s*=\s*function\s*\(e\)\s*\{(.*?)\}\s*;",
|
|
body,
|
|
re.DOTALL,
|
|
)
|
|
assert onchange_match, "listEl.onchange handler not found in renderManageViewList"
|
|
onchange_body = onchange_match.group(1)
|
|
|
|
# Must update summary in the .then() callback
|
|
then_match = re.search(
|
|
r"\.then\s*\(function\s*\(\s*\)\s*\{(.*?)\}\s*\)",
|
|
onchange_body,
|
|
re.DOTALL,
|
|
)
|
|
if then_match:
|
|
then_body = then_match.group(1)
|
|
# Must update the summary count
|
|
has_summary_update = (
|
|
"summaryEl" in then_body
|
|
or "manage-view-summary" in then_body
|
|
or "textContent" in then_body
|
|
)
|
|
assert has_summary_update, (
|
|
"renderManageViewList onChange .then() must update the summary count in-place"
|
|
)
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
# Fix 6: Clean up dead CSS
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
def test_css_no_add_sessions_tile() -> None:
|
|
""".add-sessions-tile CSS must be removed (dead code — old affordance tile)."""
|
|
assert ".add-sessions-tile {" not in _CSS and ".add-sessions-tile{" not in _CSS, (
|
|
".add-sessions-tile CSS must be removed — it's an orphaned class from old patterns"
|
|
)
|
|
|
|
|
|
def test_css_no_add_sessions_tile_icon() -> None:
|
|
""".add-sessions-tile__icon CSS must be removed (dead code)."""
|
|
assert ".add-sessions-tile__icon" not in _CSS, (
|
|
".add-sessions-tile__icon CSS must be removed — orphaned class"
|
|
)
|
|
|
|
|
|
def test_css_no_add_sessions_tile_label() -> None:
|
|
""".add-sessions-tile__label CSS must be removed (dead code)."""
|
|
assert ".add-sessions-tile__label" not in _CSS, (
|
|
".add-sessions-tile__label CSS must be removed — orphaned class"
|
|
)
|
|
|
|
|
|
def test_css_no_manage_view_panel_close_class() -> None:
|
|
""".manage-view-panel__close CSS must be removed (HTML uses __close-btn)."""
|
|
# The CSS has both __close and __close-btn — __close is orphaned
|
|
# Only check for the specific orphaned rule (not __close-btn)
|
|
# Look for the exact class name without the -btn suffix
|
|
assert ".manage-view-panel__close {" not in _CSS, (
|
|
".manage-view-panel__close CSS must be removed — HTML uses .manage-view-panel__close-btn"
|
|
)
|
|
|
|
|
|
def test_css_no_manage_view_panel_title_class() -> None:
|
|
""".manage-view-panel__title CSS must be removed (HTML uses __name)."""
|
|
assert ".manage-view-panel__title {" not in _CSS, (
|
|
".manage-view-panel__title CSS must be removed — HTML uses .manage-view-panel__name"
|
|
)
|
|
|
|
|
|
def test_css_manage_view_panel_comment_updated() -> None:
|
|
"""CSS comment must say 'Manage View Panel' not 'Add Sessions Panel'."""
|
|
assert "Add Sessions Panel" not in _CSS, (
|
|
"CSS comment 'Add Sessions Panel' must be updated to 'Manage View Panel'"
|
|
)
|
|
|
|
|
|
def test_css_manage_view_panel_name_input_exists() -> None:
|
|
""".manage-view-panel__name-input CSS must exist (needed for Fix 1 rename input)."""
|
|
assert ".manage-view-panel__name-input" in _CSS, (
|
|
".manage-view-panel__name-input CSS must exist — it's used by the rename input in Fix 1"
|
|
)
|