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