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:
+60
-24
@@ -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
@@ -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")
|
||||
|
||||
@@ -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"):
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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)"
|
||||
)
|
||||
|
||||
@@ -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 = []
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user