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") _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: def _get_install_info() -> dict:
"""Detect how muxplex was installed using PEP 610 direct_url.json. """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)" f" {warn_mark} Service: not installed (run: muxplex service install)"
) )
else: else:
systemd_user = Path.home() / ".config" / "systemd" / "user" / "muxplex.service" if not _have_systemctl():
if systemd_user.exists(): print(f" {warn_mark} Service: systemd not available on this platform")
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: else:
print( systemd_user = (
f" {warn_mark} Service: not installed (run: muxplex service install)" 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 print() # trailing newline
@@ -517,18 +529,21 @@ def upgrade(*, force: bool = False) -> None:
print(" No launchd service found (skipping stop)") print(" No launchd service found (skipping stop)")
else: else:
# Linux/WSL — check systemd # Linux/WSL — check systemd
result = subprocess.run( if not _have_systemctl():
["systemctl", "--user", "is-active", "muxplex"], print(" ! systemctl not found — skipping service management step")
capture_output=True,
text=True,
)
if result.returncode == 0:
print(" Stopping systemd service...")
subprocess.run(
["systemctl", "--user", "stop", "muxplex"], capture_output=True
)
else: 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 # 2. Reinstall via uv tool install
print(" Installing latest version...") print(" Installing latest version...")
@@ -577,10 +592,13 @@ def upgrade(*, force: bool = False) -> None:
return return
# 3. Regenerate service file (picks up any plist/unit changes) # 3. Regenerate service file (picks up any plist/unit changes)
print(" Regenerating service file...") if sys.platform == "darwin" or _have_systemctl():
from muxplex.service import service_install # noqa: PLC0415 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 # 4. Restart service
if sys.platform == "darwin": if sys.platform == "darwin":
@@ -602,7 +620,7 @@ def upgrade(*, force: bool = False) -> None:
print(" Service started (legacy)") print(" Service started (legacy)")
else: else:
print(" Service file not found — run: muxplex service install") print(" Service file not found — run: muxplex service install")
else: elif _have_systemctl():
result = subprocess.run( result = subprocess.run(
["systemctl", "--user", "is-enabled", "muxplex"], ["systemctl", "--user", "is-enabled", "muxplex"],
capture_output=True, capture_output=True,
@@ -619,6 +637,24 @@ def upgrade(*, force: bool = False) -> None:
print(" Service started") print(" Service started")
else: else:
print(" Service not enabled — run: muxplex service install") 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 # 5. Doctor check
print("\n Verifying...") print("\n Verifying...")
+44 -9
View File
@@ -74,6 +74,11 @@ def _is_darwin() -> bool:
return sys.platform == "darwin" 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: def _resolve_muxplex_bin() -> str:
"""Return the muxplex binary path. """Return the muxplex binary path.
@@ -116,8 +121,6 @@ def _prompt_host_if_localhost() -> None:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def _show_tls_nudge_if_needed() -> None: def _show_tls_nudge_if_needed() -> None:
"""Show TLS setup nudge if host is network and TLS is not configured.""" """Show TLS setup nudge if host is network and TLS is not configured."""
from muxplex.settings import load_settings 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: if host != "127.0.0.1" and not tls_cert:
print(" Tip: Enable HTTPS for clipboard support: muxplex setup-tls") print(" Tip: Enable HTTPS for clipboard support: muxplex setup-tls")
def _systemd_install() -> None: def _systemd_install() -> None:
muxplex_bin = _resolve_muxplex_bin() muxplex_bin = _resolve_muxplex_bin()
safe_path = os.environ.get("PATH", "/usr/local/bin:/usr/bin:/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: def service_install() -> None:
"""Install the muxplex service unit for the current user.""" """Install the muxplex service unit for the current user."""
if _is_darwin(): if _is_darwin():
_launchd_install() _launchd_install()
else: elif _have_systemctl():
_systemd_install() _systemd_install()
else:
_no_systemctl_error("install")
def service_uninstall() -> None: def service_uninstall() -> None:
"""Remove the muxplex service unit for the current user.""" """Remove the muxplex service unit for the current user."""
if _is_darwin(): if _is_darwin():
_launchd_uninstall() _launchd_uninstall()
else: elif _have_systemctl():
_systemd_uninstall() _systemd_uninstall()
else:
_no_systemctl_error("uninstall")
def service_start() -> None: def service_start() -> None:
"""Start the muxplex service.""" """Start the muxplex service."""
if _is_darwin(): if _is_darwin():
_launchd_start() _launchd_start()
else: elif _have_systemctl():
_systemd_start() _systemd_start()
else:
_no_systemctl_error("start")
def service_stop() -> None: def service_stop() -> None:
"""Stop the muxplex service.""" """Stop the muxplex service."""
if _is_darwin(): if _is_darwin():
_launchd_stop() _launchd_stop()
else: elif _have_systemctl():
_systemd_stop() _systemd_stop()
else:
_no_systemctl_error("stop")
def service_restart() -> None: def service_restart() -> None:
"""Restart the muxplex service.""" """Restart the muxplex service."""
if _is_darwin(): if _is_darwin():
_launchd_restart() _launchd_restart()
else: elif _have_systemctl():
_systemd_restart() _systemd_restart()
else:
_no_systemctl_error("restart")
def service_status() -> None: def service_status() -> None:
"""Print the current status of the muxplex service.""" """Print the current status of the muxplex service."""
if _is_darwin(): if _is_darwin():
_launchd_status() _launchd_status()
else: elif _have_systemctl():
_systemd_status() _systemd_status()
else:
_no_systemctl_error("status")
def service_logs() -> None: def service_logs() -> None:
"""Stream or print logs for the muxplex service.""" """Stream or print logs for the muxplex service."""
if _is_darwin(): if _is_darwin():
_launchd_logs() _launchd_logs()
else: elif _have_systemctl():
_systemd_logs() _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 mock_proc.returncode = 0
monkeypatch.setattr( 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"}) 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) shell_calls.append(cmd)
return mock_proc 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"}) response = client.post("/api/sessions", json={"name": "my-project"})
assert response.status_code == 200 assert response.status_code == 200
@@ -2468,7 +2471,8 @@ def test_create_session_logs_command(client, monkeypatch, tmp_path, caplog):
mock_proc.returncode = 0 mock_proc.returncode = 0
monkeypatch.setattr( 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"): 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 assert len(uv_calls) > 0
install_cmd = uv_calls[0] install_cmd = uv_calls[0]
assert any("git+" in str(arg) for arg in install_cmd) 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" assert match, ".manage-view-item__disclosure rule not found in style.css"
body = match.group(1) 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 — " ".add-sessions-item__disclosure must NOT have display:none — "
"the disclosure must be statically visible for hidden items (BUG 2 fix). " "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 " "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 # Check for either explicit height/min-height or a large enough line-height/padding
has_height = ( has_height = (
"min-height" in rule "min-height" in rule
or ("line-height" in rule and any( or (
f"line-height: {n}" in rule or f"line-height:{n}" in rule "line-height" in rule
for n in ["1.6", "1.7", "1.8", "1.9", "2", "22px", "23px", "24px"] 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" in rule and "padding: 2px" in rule)
or ("padding: 3px" in rule) or ("padding: 3px" in rule)
or ("padding: 4px" 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'" "Settings Commands tab must use 'Kill session command'"
"terminology must match the flyout's 'Kill Session' action" "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'" "Settings Commands tab must not use 'Delete session command'"
"rename to 'Kill session command' to match the flyout" "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()" "clicking ⋮ must NOT trigger openSession()"
) )
# Confirm the old broken guard is gone # Confirm the old broken guard is gone
assert "'tile-delete'" not in render_grid_body and '"tile-delete"' not in render_grid_body or ( assert (
"tile-options-btn" in render_grid_body "'tile-delete'" not in render_grid_body
), ( and '"tile-delete"' not in render_grid_body
"Guard must use .tile-options-btn, not the old .tile-delete which was removed" 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: 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. bubble chain). It created a false sense of correctness while doing nothing.
""" """
# Extract the tile-options-btn delegation handler block from bindStaticEventListeners # 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 # Find the section with tile-options-btn
assert "tile-options-btn" in bind_body, ( assert "tile-options-btn" in bind_body, (
"bindStaticEventListeners must have .tile-options-btn delegation" "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 # Confirm the old wrong call is gone from the mobile handler
# (openAddSessionsPanel may still exist for the grid affordance — just not here) # (openAddSessionsPanel may still exist for the grid affordance — just not here)
assert "openAddSessionsPanel" not in fn_body.split("_openMobileViewPicker")[0].split( assert (
"action === 'add-to-view'" "openAddSessionsPanel"
)[-1], ( not in fn_body.split("_openMobileViewPicker")[0].split(
"_openFlyoutSheet must not call openAddSessionsPanel for the add-to-view action" "action === 'add-to-view'"
) )[-1]
), "_openFlyoutSheet must not call openAddSessionsPanel for the add-to-view action"
# ── VIOLATION 2: submenu filters current view ──────────────────────────────── # ── 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. CLEANUP: Sheet items lacked ARIA role, breaking screen reader navigation.
""" """
fn_body = _JS.split("function _openFlyoutSheet")[1].split("\nfunction ")[0] 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" "_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" —————————————————————————— # — Issue 1: Sidebar dropdown "+ New View" ——————————————————————————
def test_render_sidebar_view_dropdown_has_new_view_action() -> None: def test_render_sidebar_view_dropdown_has_new_view_action() -> None:
"""renderSidebarViewDropdown must include a '+ New View' action button.""" """renderSidebarViewDropdown must include a '+ New View' action button."""
match = re.search( 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 ——————————————— # — Issue 2: Remove shortcut numbers, add session counts ———————————————
def test_render_view_dropdown_no_shortcut_spans() -> None: def test_render_view_dropdown_no_shortcut_spans() -> None:
"""renderViewDropdown must not include view-dropdown__shortcut spans (numbers removed).""" """renderViewDropdown must not include view-dropdown__shortcut spans (numbers removed)."""
match = re.search( 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 ——————————————————— # — Issue 3: Empty new view opens Add Sessions panel ———————————————————
def test_show_new_view_input_calls_open_manage_view_panel() -> None: def test_show_new_view_input_calls_open_manage_view_panel() -> None:
"""showNewViewInput must call openManageViewPanel after creating a new view.""" """showNewViewInput must call openManageViewPanel after creating a new view."""
match = re.search( 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" ——————————————————————————————— # — Issue 4: Flyout submenu "+ New View" ———————————————————————————————
def test_open_flyout_submenu_has_new_view_option() -> None: def test_open_flyout_submenu_has_new_view_option() -> None:
"""_openFlyoutSubmenu must include a '+ New View' option.""" """_openFlyoutSubmenu must include a '+ New View' option."""
match = re.search( 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' '_openFlyoutSubmenu must call switchView — the "+ New View" handler needs to switch to the new view'
) )
assert "PATCH" in body or "api(" in body, ( 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 ———————————————————————————————— # — Issue 5: Add Sessions header button ————————————————————————————————
def test_update_add_sessions_button_removed() -> None: def test_update_add_sessions_button_removed() -> None:
"""updateAddSessionsButton must be REMOVED — the + Add button is gone (Issue 5).""" """updateAddSessionsButton must be REMOVED — the + Add button is gone (Issue 5)."""
assert "function updateAddSessionsButton" not in _JS, ( 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 ———————————————————————————————— # — Issue 6: Tile header flexbox layout ————————————————————————————————
def test_build_tile_html_options_btn_inside_tile_header() -> None: def test_build_tile_html_options_btn_inside_tile_header() -> None:
"""buildTileHTML must render tile-options-btn inside tile-header (before tile-body).""" """buildTileHTML must render tile-options-btn inside tile-header (before tile-body)."""
fn_body = _JS.split("function buildTileHTML")[1].split("\nfunction ")[0] 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) match = _re.search(r"\.tile-options-btn\s*\{([^}]*)\}", _CSS, _re.DOTALL)
assert match, ".tile-options-btn CSS rule not found" assert match, ".tile-options-btn CSS rule not found"
rule_body = match.group(1) 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 — " ".tile-options-btn must not use position:absolute — "
"it should be an inline flex item inside tile-header to prevent device badge overlap" "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" assert match, "buildSidebarHTML function not found"
body = match.group(1) 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' " "buildSidebarHTML tile-options-btn must have aria-label='Session options' "
"for accessibility (same as tile version)" "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) monkeypatch.setattr(settings_mod, "SETTINGS_PATH", settings_file)
# Setup settings with network host and no TLS # 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 # Mock subprocess to avoid actual systemctl calls
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) monkeypatch.setattr(settings_mod, "SETTINGS_PATH", settings_file)
# Setup settings with localhost # 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 # Mock subprocess
calls = [] 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: def test_manage_view_rename_patches_settings() -> None:
"""The rename flow must PATCH /api/settings with updated view name.""" """The rename flow must PATCH /api/settings with updated view name."""
# The rename action must call PATCH /api/settings # The rename action must call PATCH /api/settings
assert "PATCH" in _JS and "/api/settings" in _JS, ( assert "PATCH" in _JS and "/api/settings" in _JS, "Rename must PATCH /api/settings"
"Rename must PATCH /api/settings"
)
def test_manage_view_rename_handles_escape() -> None: 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() "delete" in body.lower()
or "trash" in body.lower() or "trash" in body.lower()
or "manage-view-delete" in body or "manage-view-delete" in body
or "data-action=\"delete\"" in body or 'data-action="delete"' in body
)
assert has_delete, (
"openManageViewPanel must render a delete button in the header"
) )
assert has_delete, "openManageViewPanel must render a delete button in the header"
def test_manage_view_delete_shows_confirmation() -> None: 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" assert match, "openManageViewPanel function not found"
body = match.group(1) body = match.group(1)
# openManageViewPanel must either patch directly or call a helper that does # 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, ( assert (
"Manage View delete must call PATCH /api/settings to remove the view" "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: 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" assert match, "openManageViewPanel function not found"
body = match.group(1) body = match.group(1)
assert "showToast" in body, ( assert "showToast" in body, "Manage View delete must call showToast after deleting"
"Manage View delete must call showToast after deleting"
)
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────
Generated
+1 -1
View File
@@ -332,7 +332,7 @@ wheels = [
[[package]] [[package]]
name = "muxplex" name = "muxplex"
version = "0.4.6" version = "0.6.0"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "aiofiles" }, { name = "aiofiles" },