From d7d07ec07e0f180c1bc75a85beb21c13d6fa4ecc Mon Sep 17 00:00:00 2001 From: Brian Krabach Date: Sun, 17 May 2026 12:26:20 -0700 Subject: [PATCH] fix(cli): tolerate systems without systemctl in upgrade/doctor flows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- muxplex/cli.py | 84 ++++++++--- muxplex/service.py | 53 +++++-- muxplex/tests/test_api.py | 10 +- muxplex/tests/test_cli.py | 226 ++++++++++++++++++++++++++++ muxplex/tests/test_frontend_css.py | 15 +- muxplex/tests/test_frontend_html.py | 4 +- muxplex/tests/test_frontend_js.py | 43 ++++-- muxplex/tests/test_service.py | 8 +- muxplex/tests/test_ux_fixes.py | 20 +-- uv.lock | 2 +- 10 files changed, 392 insertions(+), 73 deletions(-) diff --git a/muxplex/cli.py b/muxplex/cli.py index b3989e4..4aff5fa 100644 --- a/muxplex/cli.py +++ b/muxplex/cli.py @@ -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...") diff --git a/muxplex/service.py b/muxplex/service.py index 70436b6..b3674d1 100644 --- a/muxplex/service.py +++ b/muxplex/service.py @@ -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") diff --git a/muxplex/tests/test_api.py b/muxplex/tests/test_api.py index 4fa30d1..5f57129 100644 --- a/muxplex/tests/test_api.py +++ b/muxplex/tests/test_api.py @@ -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"): diff --git a/muxplex/tests/test_cli.py b/muxplex/tests/test_cli.py index 6560307..4061466 100644 --- a/muxplex/tests/test_cli.py +++ b/muxplex/tests/test_cli.py @@ -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() diff --git a/muxplex/tests/test_frontend_css.py b/muxplex/tests/test_frontend_css.py index cec5610..dc5f539 100644 --- a/muxplex/tests/test_frontend_css.py +++ b/muxplex/tests/test_frontend_css.py @@ -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) diff --git a/muxplex/tests/test_frontend_html.py b/muxplex/tests/test_frontend_html.py index bf5367b..2fd3f01 100644 --- a/muxplex/tests/test_frontend_html.py +++ b/muxplex/tests/test_frontend_html.py @@ -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" ) diff --git a/muxplex/tests/test_frontend_js.py b/muxplex/tests/test_frontend_js.py index 276ee7a..1d14928 100644 --- a/muxplex/tests/test_frontend_js.py +++ b/muxplex/tests/test_frontend_js.py @@ -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)" ) diff --git a/muxplex/tests/test_service.py b/muxplex/tests/test_service.py index b91b755..790d047 100644 --- a/muxplex/tests/test_service.py +++ b/muxplex/tests/test_service.py @@ -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 = [] diff --git a/muxplex/tests/test_ux_fixes.py b/muxplex/tests/test_ux_fixes.py index ebda7f7..e77ec23 100644 --- a/muxplex/tests/test_ux_fixes.py +++ b/muxplex/tests/test_ux_fixes.py @@ -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" # ───────────────────────────────────────────────────────────────────────────── diff --git a/uv.lock b/uv.lock index a92b556..96f19bb 100644 --- a/uv.lock +++ b/uv.lock @@ -332,7 +332,7 @@ wheels = [ [[package]] name = "muxplex" -version = "0.4.6" +version = "0.6.0" source = { editable = "." } dependencies = [ { name = "aiofiles" },