fix(cli): tolerate systems without systemctl in upgrade/doctor flows

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
This commit is contained in:
Brian Krabach
2026-05-17 12:26:20 -07:00
parent 794e329938
commit d7d07ec07e
10 changed files with 392 additions and 73 deletions
+60 -24
View File
@@ -21,6 +21,11 @@ from muxplex.auth import (
_system_service_path = Path("/etc/systemd/system/muxplex.service")
def _have_systemctl() -> bool:
"""Return True if systemctl is on PATH (used to gate service-management steps)."""
return shutil.which("systemctl") is not None
def _get_install_info() -> dict:
"""Detect how muxplex was installed using PEP 610 direct_url.json.
@@ -443,17 +448,24 @@ def doctor() -> None:
f" {warn_mark} Service: not installed (run: muxplex service install)"
)
else:
systemd_user = Path.home() / ".config" / "systemd" / "user" / "muxplex.service"
if systemd_user.exists():
print(f" {ok_mark} Service: systemd user unit installed ({systemd_user})")
elif _system_service_path.exists():
print(
f" {ok_mark} Service: systemd system unit installed ({_system_service_path})"
)
if not _have_systemctl():
print(f" {warn_mark} Service: systemd not available on this platform")
else:
print(
f" {warn_mark} Service: not installed (run: muxplex service install)"
systemd_user = (
Path.home() / ".config" / "systemd" / "user" / "muxplex.service"
)
if systemd_user.exists():
print(
f" {ok_mark} Service: systemd user unit installed ({systemd_user})"
)
elif _system_service_path.exists():
print(
f" {ok_mark} Service: systemd system unit installed ({_system_service_path})"
)
else:
print(
f" {warn_mark} Service: not installed (run: muxplex service install)"
)
print() # trailing newline
@@ -517,18 +529,21 @@ def upgrade(*, force: bool = False) -> None:
print(" No launchd service found (skipping stop)")
else:
# Linux/WSL — check systemd
result = subprocess.run(
["systemctl", "--user", "is-active", "muxplex"],
capture_output=True,
text=True,
)
if result.returncode == 0:
print(" Stopping systemd service...")
subprocess.run(
["systemctl", "--user", "stop", "muxplex"], capture_output=True
)
if not _have_systemctl():
print(" ! systemctl not found — skipping service management step")
else:
print(" No active systemd service found (skipping stop)")
result = subprocess.run(
["systemctl", "--user", "is-active", "muxplex"],
capture_output=True,
text=True,
)
if result.returncode == 0:
print(" Stopping systemd service...")
subprocess.run(
["systemctl", "--user", "stop", "muxplex"], capture_output=True
)
else:
print(" No active systemd service found (skipping stop)")
# 2. Reinstall via uv tool install
print(" Installing latest version...")
@@ -577,10 +592,13 @@ def upgrade(*, force: bool = False) -> None:
return
# 3. Regenerate service file (picks up any plist/unit changes)
print(" Regenerating service file...")
from muxplex.service import service_install # noqa: PLC0415
if sys.platform == "darwin" or _have_systemctl():
print(" Regenerating service file...")
from muxplex.service import service_install # noqa: PLC0415
service_install()
service_install()
else:
print(" ! systemctl not found — skipping service file regeneration")
# 4. Restart service
if sys.platform == "darwin":
@@ -602,7 +620,7 @@ def upgrade(*, force: bool = False) -> None:
print(" Service started (legacy)")
else:
print(" Service file not found — run: muxplex service install")
else:
elif _have_systemctl():
result = subprocess.run(
["systemctl", "--user", "is-enabled", "muxplex"],
capture_output=True,
@@ -619,6 +637,24 @@ def upgrade(*, force: bool = False) -> None:
print(" Service started")
else:
print(" Service not enabled — run: muxplex service install")
else:
# No systemd available — ask the user to restart manually
pid_hint = ""
try:
pid_result = subprocess.run(
["pgrep", "-f", "muxplex serve"],
capture_output=True,
text=True,
)
pid_str = pid_result.stdout.strip()
if pid_str:
pid_hint = f" (running PID: {pid_str})"
except Exception:
pass
print(
" ! systemd not detected — restart muxplex manually to pick up the new version"
+ pid_hint
)
# 5. Doctor check
print("\n Verifying...")
+44 -9
View File
@@ -74,6 +74,11 @@ def _is_darwin() -> bool:
return sys.platform == "darwin"
def _have_systemctl() -> bool:
"""Return True if systemctl is on PATH (gates all systemd service operations)."""
return shutil.which("systemctl") is not None
def _resolve_muxplex_bin() -> str:
"""Return the muxplex binary path.
@@ -116,8 +121,6 @@ def _prompt_host_if_localhost() -> None:
# ---------------------------------------------------------------------------
def _show_tls_nudge_if_needed() -> None:
"""Show TLS setup nudge if host is network and TLS is not configured."""
from muxplex.settings import load_settings
@@ -128,6 +131,8 @@ def _show_tls_nudge_if_needed() -> None:
if host != "127.0.0.1" and not tls_cert:
print(" Tip: Enable HTTPS for clipboard support: muxplex setup-tls")
def _systemd_install() -> None:
muxplex_bin = _resolve_muxplex_bin()
safe_path = os.environ.get("PATH", "/usr/local/bin:/usr/bin:/bin")
@@ -235,57 +240,87 @@ def _launchd_logs() -> None:
# ---------------------------------------------------------------------------
def _no_systemctl_error(command: str) -> None:
"""Print a clear error when systemctl is not available."""
print(
f" ERROR: 'muxplex service {command}' requires systemctl, which was not found on PATH.",
file=sys.stderr,
)
print(
" This system does not appear to use systemd (e.g. Unraid, BSD, macOS, container).",
file=sys.stderr,
)
print(
" Run muxplex serve directly to start the server without a service manager.",
file=sys.stderr,
)
def service_install() -> None:
"""Install the muxplex service unit for the current user."""
if _is_darwin():
_launchd_install()
else:
elif _have_systemctl():
_systemd_install()
else:
_no_systemctl_error("install")
def service_uninstall() -> None:
"""Remove the muxplex service unit for the current user."""
if _is_darwin():
_launchd_uninstall()
else:
elif _have_systemctl():
_systemd_uninstall()
else:
_no_systemctl_error("uninstall")
def service_start() -> None:
"""Start the muxplex service."""
if _is_darwin():
_launchd_start()
else:
elif _have_systemctl():
_systemd_start()
else:
_no_systemctl_error("start")
def service_stop() -> None:
"""Stop the muxplex service."""
if _is_darwin():
_launchd_stop()
else:
elif _have_systemctl():
_systemd_stop()
else:
_no_systemctl_error("stop")
def service_restart() -> None:
"""Restart the muxplex service."""
if _is_darwin():
_launchd_restart()
else:
elif _have_systemctl():
_systemd_restart()
else:
_no_systemctl_error("restart")
def service_status() -> None:
"""Print the current status of the muxplex service."""
if _is_darwin():
_launchd_status()
else:
elif _have_systemctl():
_systemd_status()
else:
_no_systemctl_error("status")
def service_logs() -> None:
"""Stream or print logs for the muxplex service."""
if _is_darwin():
_launchd_logs()
else:
elif _have_systemctl():
_systemd_logs()
else:
_no_systemctl_error("logs")
+7 -3
View File
@@ -1361,7 +1361,8 @@ def test_create_session_returns_200_with_name(client, monkeypatch):
mock_proc.returncode = 0
monkeypatch.setattr(
"muxplex.main.asyncio.create_subprocess_shell", AsyncMock(return_value=mock_proc)
"muxplex.main.asyncio.create_subprocess_shell",
AsyncMock(return_value=mock_proc),
)
response = client.post("/api/sessions", json={"name": "my-project"})
@@ -1391,7 +1392,9 @@ def test_create_session_substitutes_name_in_template(client, tmp_path, monkeypat
shell_calls.append(cmd)
return mock_proc
monkeypatch.setattr("muxplex.main.asyncio.create_subprocess_shell", mock_create_subprocess)
monkeypatch.setattr(
"muxplex.main.asyncio.create_subprocess_shell", mock_create_subprocess
)
response = client.post("/api/sessions", json={"name": "my-project"})
assert response.status_code == 200
@@ -2468,7 +2471,8 @@ def test_create_session_logs_command(client, monkeypatch, tmp_path, caplog):
mock_proc.returncode = 0
monkeypatch.setattr(
"muxplex.main.asyncio.create_subprocess_shell", AsyncMock(return_value=mock_proc)
"muxplex.main.asyncio.create_subprocess_shell",
AsyncMock(return_value=mock_proc),
)
with caplog.at_level(logging.INFO, logger="muxplex.main"):
+226
View File
@@ -2257,3 +2257,229 @@ def test_upgrade_git_install_uses_git_url(monkeypatch, capsys):
assert len(uv_calls) > 0
install_cmd = uv_calls[0]
assert any("git+" in str(arg) for arg in install_cmd)
# ---------------------------------------------------------------------------
# v0.6.1 bug-fix: tolerate systems without systemctl
# ---------------------------------------------------------------------------
def test_have_systemctl_helper_exists():
"""_have_systemctl must be importable from muxplex.cli."""
from muxplex.cli import _have_systemctl # noqa: F401
def test_have_systemctl_returns_bool(monkeypatch):
"""_have_systemctl() must return True when systemctl is on PATH, False otherwise."""
from muxplex.cli import _have_systemctl
monkeypatch.setattr(
shutil,
"which",
lambda name: "/usr/bin/systemctl" if name == "systemctl" else None,
)
assert _have_systemctl() is True
monkeypatch.setattr(shutil, "which", lambda name: None)
assert _have_systemctl() is False
def test_upgrade_no_systemctl_runs_to_completion(monkeypatch, capsys):
"""upgrade() must complete without raising when systemctl is not on PATH.
Regression test for FileNotFoundError on Unraid / BSD / macOS-container hosts.
"""
import subprocess
import sys
import muxplex.cli as cli_mod
subprocess_calls = []
def mock_run(cmd, **kwargs):
subprocess_calls.append(cmd)
return type("R", (), {"returncode": 0, "stdout": "", "stderr": ""})()
# systemctl absent; uv and pgrep present
def fake_which_no_systemctl(name):
if name == "systemctl":
return None
return f"/usr/bin/{name}"
monkeypatch.setattr(shutil, "which", fake_which_no_systemctl)
monkeypatch.setattr(subprocess, "run", mock_run)
monkeypatch.setattr(cli_mod, "doctor", lambda: None)
monkeypatch.setattr(
cli_mod,
"_check_for_update",
lambda info: (True, "update available (v0.6.0 → v0.6.1)"),
)
# Ensure we exercise the Linux (non-darwin) path
monkeypatch.setattr(sys, "platform", "linux")
# Must NOT raise FileNotFoundError (the original bug)
cli_mod.upgrade()
# systemctl must never have been called
systemctl_calls = [
c for c in subprocess_calls if isinstance(c, list) and "systemctl" in c
]
assert len(systemctl_calls) == 0, (
f"upgrade() must not call systemctl when _have_systemctl() is False; "
f"got: {systemctl_calls}"
)
def test_upgrade_no_systemctl_prints_skip_note(monkeypatch, capsys):
"""upgrade() must print a helpful note when systemctl is missing."""
import subprocess
import sys
import muxplex.cli as cli_mod
def mock_run(cmd, **kwargs):
return type("R", (), {"returncode": 0, "stdout": "", "stderr": ""})()
def fake_which_no_systemctl(name):
if name == "systemctl":
return None
return f"/usr/bin/{name}"
monkeypatch.setattr(shutil, "which", fake_which_no_systemctl)
monkeypatch.setattr(subprocess, "run", mock_run)
monkeypatch.setattr(cli_mod, "doctor", lambda: None)
monkeypatch.setattr(
cli_mod,
"_check_for_update",
lambda info: (True, "update available (v0.6.0 → v0.6.1)"),
)
monkeypatch.setattr(sys, "platform", "linux")
cli_mod.upgrade()
out = capsys.readouterr().out
out_lower = out.lower()
# Must mention that systemctl was not found / skipped at least once
assert "systemctl" in out_lower, (
f"upgrade() must print a note mentioning 'systemctl' when it is absent; got: {out!r}"
)
assert (
"not found" in out_lower
or "skipping" in out_lower
or "not detected" in out_lower
), f"upgrade() must indicate the step was skipped; got: {out!r}"
def test_upgrade_no_systemctl_prints_manual_restart_note(monkeypatch, capsys):
"""upgrade() must tell the user to restart muxplex manually when systemd is absent."""
import subprocess
import sys
import muxplex.cli as cli_mod
def mock_run(cmd, **kwargs):
return type("R", (), {"returncode": 0, "stdout": "", "stderr": ""})()
def fake_which_no_systemctl(name):
if name == "systemctl":
return None
return f"/usr/bin/{name}"
monkeypatch.setattr(shutil, "which", fake_which_no_systemctl)
monkeypatch.setattr(subprocess, "run", mock_run)
monkeypatch.setattr(cli_mod, "doctor", lambda: None)
monkeypatch.setattr(
cli_mod,
"_check_for_update",
lambda info: (True, "update available (v0.6.0 → v0.6.1)"),
)
monkeypatch.setattr(sys, "platform", "linux")
cli_mod.upgrade()
out = capsys.readouterr().out
out_lower = out.lower()
assert "restart" in out_lower or "manually" in out_lower, (
f"upgrade() must advise manual restart when systemd is absent; got: {out!r}"
)
def test_upgrade_with_systemctl_runs_systemd_commands(monkeypatch, capsys):
"""upgrade() must call systemctl when it IS available (full Linux systemd path)."""
import subprocess
import sys
import muxplex.cli as cli_mod
subprocess_calls = []
def mock_run(cmd, **kwargs):
subprocess_calls.append(cmd)
return type("R", (), {"returncode": 0, "stdout": "", "stderr": ""})()
monkeypatch.setattr(shutil, "which", lambda name: f"/usr/bin/{name}")
monkeypatch.setattr(subprocess, "run", mock_run)
monkeypatch.setattr(cli_mod, "doctor", lambda: None)
monkeypatch.setattr(
cli_mod,
"_check_for_update",
lambda info: (True, "update available (v0.6.0 → v0.6.1)"),
)
monkeypatch.setattr(sys, "platform", "linux")
with patch("muxplex.service.service_install", lambda: None):
cli_mod.upgrade()
systemctl_calls = [
c for c in subprocess_calls if isinstance(c, list) and "systemctl" in c
]
assert len(systemctl_calls) > 0, (
"upgrade() must invoke systemctl commands when systemctl is available"
)
# Verify the is-active check was performed
is_active_calls = [c for c in systemctl_calls if "is-active" in c]
assert len(is_active_calls) > 0, (
"upgrade() must check is-active when systemctl is available"
)
def test_doctor_no_systemctl_shows_graceful_message(monkeypatch, capsys):
"""doctor() must show 'systemd not available' when systemctl is not on PATH."""
original_which = shutil.which
def fake_which_no_systemctl(name):
if name == "systemctl":
return None
return original_which(name)
monkeypatch.setattr(shutil, "which", fake_which_no_systemctl)
from muxplex.cli import doctor
doctor()
out = capsys.readouterr().out
out_lower = out.lower()
assert "systemd" in out_lower, (
f"doctor() must mention 'systemd' in Service line when systemctl is absent; got: {out!r}"
)
assert "not available" in out_lower or "unavailable" in out_lower, (
f"doctor() must say systemd is 'not available' when systemctl is absent; got: {out!r}"
)
def test_doctor_no_systemctl_does_not_crash(monkeypatch, capsys):
"""doctor() must not raise FileNotFoundError or any exception when systemctl is absent."""
original_which = shutil.which
def fake_which_no_systemctl(name):
if name == "systemctl":
return None
return original_which(name)
monkeypatch.setattr(shutil, "which", fake_which_no_systemctl)
from muxplex.cli import doctor
# Must not raise — the original bug was FileNotFoundError on systems without systemctl
doctor()
+10 -5
View File
@@ -2251,7 +2251,9 @@ def test_disclosure_not_hidden_by_css() -> None:
)
assert match, ".manage-view-item__disclosure rule not found in style.css"
body = match.group(1)
assert "display: none" not in body and "display:none" not in body.replace(" ", ""), (
assert "display: none" not in body and "display:none" not in body.replace(
" ", ""
), (
".add-sessions-item__disclosure must NOT have display:none — "
"the disclosure must be statically visible for hidden items (BUG 2 fix). "
"The hover-based show/hide was broken: setting disc.style.display='' doesn't "
@@ -2350,10 +2352,13 @@ def test_device_badge_height_matches_flyout_button() -> None:
# Check for either explicit height/min-height or a large enough line-height/padding
has_height = (
"min-height" in rule
or ("line-height" in rule and any(
f"line-height: {n}" in rule or f"line-height:{n}" in rule
for n in ["1.6", "1.7", "1.8", "1.9", "2", "22px", "23px", "24px"]
))
or (
"line-height" in rule
and any(
f"line-height: {n}" in rule or f"line-height:{n}" in rule
for n in ["1.6", "1.7", "1.8", "1.9", "2", "22px", "23px", "24px"]
)
)
or ("padding" in rule and "padding: 2px" in rule)
or ("padding: 3px" in rule)
or ("padding: 4px" in rule)
+3 -1
View File
@@ -1582,7 +1582,9 @@ def test_kill_session_command_label() -> None:
"Settings Commands tab must use 'Kill session command'"
"terminology must match the flyout's 'Kill Session' action"
)
assert "Delete session command" not in html and "Delete Session Command" not in html, (
assert (
"Delete session command" not in html and "Delete Session Command" not in html
), (
"Settings Commands tab must not use 'Delete session command'"
"rename to 'Kill session command' to match the flyout"
)
+28 -15
View File
@@ -4018,11 +4018,11 @@ def test_tile_click_handler_guards_options_btn() -> None:
"clicking ⋮ must NOT trigger openSession()"
)
# Confirm the old broken guard is gone
assert "'tile-delete'" not in render_grid_body and '"tile-delete"' not in render_grid_body or (
"tile-options-btn" in render_grid_body
), (
"Guard must use .tile-options-btn, not the old .tile-delete which was removed"
)
assert (
"'tile-delete'" not in render_grid_body
and '"tile-delete"' not in render_grid_body
or ("tile-options-btn" in render_grid_body)
), "Guard must use .tile-options-btn, not the old .tile-delete which was removed"
def test_flyout_delegation_handler_no_stop_propagation() -> None:
@@ -4032,7 +4032,9 @@ def test_flyout_delegation_handler_no_stop_propagation() -> None:
bubble chain). It created a false sense of correctness while doing nothing.
"""
# Extract the tile-options-btn delegation handler block from bindStaticEventListeners
bind_body = _JS.split("function bindStaticEventListeners")[1].split("\nfunction ")[0]
bind_body = _JS.split("function bindStaticEventListeners")[1].split("\nfunction ")[
0
]
# Find the section with tile-options-btn
assert "tile-options-btn" in bind_body, (
"bindStaticEventListeners must have .tile-options-btn delegation"
@@ -4119,11 +4121,12 @@ def test_open_flyout_sheet_add_to_view_calls_view_picker() -> None:
)
# Confirm the old wrong call is gone from the mobile handler
# (openAddSessionsPanel may still exist for the grid affordance — just not here)
assert "openAddSessionsPanel" not in fn_body.split("_openMobileViewPicker")[0].split(
"action === 'add-to-view'"
)[-1], (
"_openFlyoutSheet must not call openAddSessionsPanel for the add-to-view action"
)
assert (
"openAddSessionsPanel"
not in fn_body.split("_openMobileViewPicker")[0].split(
"action === 'add-to-view'"
)[-1]
), "_openFlyoutSheet must not call openAddSessionsPanel for the add-to-view action"
# ── VIOLATION 2: submenu filters current view ────────────────────────────────
@@ -4197,7 +4200,7 @@ def test_flyout_sheet_items_have_role_menuitem() -> None:
CLEANUP: Sheet items lacked ARIA role, breaking screen reader navigation.
"""
fn_body = _JS.split("function _openFlyoutSheet")[1].split("\nfunction ")[0]
assert "role=\"menuitem\"" in fn_body or "role='menuitem'" in fn_body, (
assert 'role="menuitem"' in fn_body or "role='menuitem'" in fn_body, (
"_openFlyoutSheet must set role='menuitem' on each sheet item button"
)
@@ -4291,6 +4294,7 @@ _CSS: str = CSS_PATH.read_text()
# — Issue 1: Sidebar dropdown "+ New View" ——————————————————————————
def test_render_sidebar_view_dropdown_has_new_view_action() -> None:
"""renderSidebarViewDropdown must include a '+ New View' action button."""
match = re.search(
@@ -4328,6 +4332,7 @@ def test_bind_static_event_listeners_calls_show_sidebar_new_view_input() -> None
# — Issue 2: Remove shortcut numbers, add session counts ———————————————
def test_render_view_dropdown_no_shortcut_spans() -> None:
"""renderViewDropdown must not include view-dropdown__shortcut spans (numbers removed)."""
match = re.search(
@@ -4378,6 +4383,7 @@ def test_render_view_dropdown_shows_user_view_session_count() -> None:
# — Issue 3: Empty new view opens Add Sessions panel ———————————————————
def test_show_new_view_input_calls_open_manage_view_panel() -> None:
"""showNewViewInput must call openManageViewPanel after creating a new view."""
match = re.search(
@@ -4409,6 +4415,7 @@ def test_show_sidebar_new_view_input_calls_open_manage_view_panel() -> None:
# — Issue 4: Flyout submenu "+ New View" ———————————————————————————————
def test_open_flyout_submenu_has_new_view_option() -> None:
"""_openFlyoutSubmenu must include a '+ New View' option."""
match = re.search(
@@ -4440,12 +4447,13 @@ def test_open_flyout_submenu_new_view_creates_and_switches() -> None:
'_openFlyoutSubmenu must call switchView — the "+ New View" handler needs to switch to the new view'
)
assert "PATCH" in body or "api(" in body, (
'_openFlyoutSubmenu must PATCH /api/settings to create the view'
"_openFlyoutSubmenu must PATCH /api/settings to create the view"
)
# — Issue 5: Add Sessions header button ————————————————————————————————
def test_update_add_sessions_button_removed() -> None:
"""updateAddSessionsButton must be REMOVED — the + Add button is gone (Issue 5)."""
assert "function updateAddSessionsButton" not in _JS, (
@@ -4494,6 +4502,7 @@ def test_bind_static_event_listeners_no_add_sessions_btn() -> None:
# — Issue 6: Tile header flexbox layout ————————————————————————————————
def test_build_tile_html_options_btn_inside_tile_header() -> None:
"""buildTileHTML must render tile-options-btn inside tile-header (before tile-body)."""
fn_body = _JS.split("function buildTileHTML")[1].split("\nfunction ")[0]
@@ -4514,7 +4523,9 @@ def test_tile_options_btn_css_not_absolute() -> None:
match = _re.search(r"\.tile-options-btn\s*\{([^}]*)\}", _CSS, _re.DOTALL)
assert match, ".tile-options-btn CSS rule not found"
rule_body = match.group(1)
assert "position: absolute" not in rule_body and "position:absolute" not in rule_body, (
assert (
"position: absolute" not in rule_body and "position:absolute" not in rule_body
), (
".tile-options-btn must not use position:absolute — "
"it should be an inline flex item inside tile-header to prevent device badge overlap"
)
@@ -4710,7 +4721,9 @@ def test_build_sidebar_html_options_btn_has_aria_label() -> None:
)
assert match, "buildSidebarHTML function not found"
body = match.group(1)
assert 'aria-label="Session options"' in body or "aria-label='Session options'" in body, (
assert (
'aria-label="Session options"' in body or "aria-label='Session options'" in body
), (
"buildSidebarHTML tile-options-btn must have aria-label='Session options' "
"for accessibility (same as tile version)"
)
+6 -2
View File
@@ -610,7 +610,9 @@ def test_service_install_shows_tls_tip_on_network_host(capsys, tmp_path, monkeyp
monkeypatch.setattr(settings_mod, "SETTINGS_PATH", settings_file)
# Setup settings with network host and no TLS
settings_file.write_text(json.dumps({"host": "0.0.0.0", "tls_cert": "", "tls_key": ""}))
settings_file.write_text(
json.dumps({"host": "0.0.0.0", "tls_cert": "", "tls_key": ""})
)
# Mock subprocess to avoid actual systemctl calls
calls = []
@@ -646,7 +648,9 @@ def test_service_install_hides_tls_tip_on_localhost(capsys, tmp_path, monkeypatc
monkeypatch.setattr(settings_mod, "SETTINGS_PATH", settings_file)
# Setup settings with localhost
settings_file.write_text(json.dumps({"host": "127.0.0.1", "tls_cert": "", "tls_key": ""}))
settings_file.write_text(
json.dumps({"host": "127.0.0.1", "tls_cert": "", "tls_key": ""})
)
# Mock subprocess
calls = []
+7 -13
View File
@@ -53,9 +53,7 @@ def test_manage_view_rename_validates_non_empty() -> None:
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"
)
assert "PATCH" in _JS and "/api/settings" in _JS, "Rename must PATCH /api/settings"
def test_manage_view_rename_handles_escape() -> None:
@@ -126,11 +124,9 @@ def test_open_manage_view_panel_has_delete_button() -> None:
"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"
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:
@@ -167,9 +163,9 @@ def test_manage_view_delete_removes_view_from_settings() -> None:
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"
)
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:
@@ -195,9 +191,7 @@ def test_manage_view_delete_shows_toast() -> None:
)
assert match, "openManageViewPanel function not found"
body = match.group(1)
assert "showToast" in body, (
"Manage View delete must call showToast after deleting"
)
assert "showToast" in body, "Manage View delete must call showToast after deleting"
# ─────────────────────────────────────────────────────────────────────────────
Generated
+1 -1
View File
@@ -332,7 +332,7 @@ wheels = [
[[package]]
name = "muxplex"
version = "0.4.6"
version = "0.6.0"
source = { editable = "." }
dependencies = [
{ name = "aiofiles" },