diff --git a/CHANGELOG.md b/CHANGELOG.md index 5b8c95e..9c80b47 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -52,6 +52,10 @@ and this project adheres to [Semantic Versioning 2.0.0](https://semver.org/spec/ - Version badge in README bumped to 0.1.1. ### Fixed +- **`mm` / `mm net` / `mm watch` não crasham mais** quando + `system_profiler SPAirPortDataType` passa do timeout de 5s, ou quando + `ipconfig` não existe. A coleta de rede degrada para Wi-Fi vazio + / sem IP local — o mesmo padrão que `security._run` já usava. - **Caching bug discovered by the new tests**: `cache.cached` and `cache.peek` promised to fall back to a plain function call when called with unhashable arguments (e.g. a `dict`), but only diff --git a/macmanager/network.py b/macmanager/network.py index 8dd922b..bd74008 100644 --- a/macmanager/network.py +++ b/macmanager/network.py @@ -30,13 +30,27 @@ class NetworkInfo: security: Optional[str] +def _run(cmd: list[str], timeout: int = 5) -> str: + """Devolve o stdout. Timeout, binário ausente e erro de OS viram "". + + Quem chama degrada para "desconhecido" em vez de derrubar `mm` / + `mm net` / `mm watch`. Espelha `macmanager.security._run`. + """ + try: + p = subprocess.run( + cmd, + capture_output=True, + text=True, + check=False, + timeout=timeout, + ) + return p.stdout + except (subprocess.TimeoutExpired, FileNotFoundError, OSError): + return "" + + def _local_ip(iface: str = "en0") -> Optional[str]: - out = subprocess.run( - ["ipconfig", "getifaddr", iface], - capture_output=True, - text=True, - check=False, - ).stdout.strip() + out = _run(["ipconfig", "getifaddr", iface], timeout=2).strip() return out or None @@ -57,13 +71,7 @@ def _wifi_info() -> dict: (no sudo).""" data: dict = {} - out = subprocess.run( - ["/usr/sbin/system_profiler", "SPAirPortDataType"], - capture_output=True, - text=True, - check=False, - timeout=5, - ).stdout + out = _run(["/usr/sbin/system_profiler", "SPAirPortDataType"], timeout=5) cur = re.search(r"Current Network Information:\s*\n\s*([^\n]+):", out) if cur: diff --git a/tests/test_network.py b/tests/test_network.py index 81077ff..392f71b 100644 --- a/tests/test_network.py +++ b/tests/test_network.py @@ -2,9 +2,14 @@ from __future__ import annotations +import subprocess +from collections.abc import Iterator +from types import SimpleNamespace + import pytest -from macmanager.network import _signal_quality +from macmanager.cache import clear_all +from macmanager.network import _local_ip, _run, _signal_quality, _wifi_info class TestSignalQuality: @@ -42,3 +47,77 @@ def test_positive_rssi_is_treated_as_excellent(self) -> None: # doesn't special-case positive values — they trivially satisfy # `>= -55`. assert _signal_quality(0) == ("Excellent", "green") + + +class TestRun: + """`_run` é a fronteira de crash de todo shell-out de rede. Timeout + ou binário ausente tem que virar string vazia — nunca uma exceção + que derrube `mm` / `mm net` / `mm watch`.""" + + def test_returns_stdout_on_success(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr( + "macmanager.network.subprocess.run", + lambda *args, **kwargs: SimpleNamespace(stdout="192.168.1.10\n"), + ) + assert _run(["ipconfig", "getifaddr", "en0"]) == "192.168.1.10\n" + + @pytest.mark.parametrize( + "exc", + [ + subprocess.TimeoutExpired(cmd="system_profiler", timeout=5), + FileNotFoundError("ipconfig"), + OSError("permission denied"), + ], + ) + def test_swallows_expected_failures( + self, monkeypatch: pytest.MonkeyPatch, exc: Exception + ) -> None: + def boom(*args: object, **kwargs: object) -> None: + raise exc + + monkeypatch.setattr("macmanager.network.subprocess.run", boom) + assert _run(["/usr/sbin/system_profiler", "SPAirPortDataType"]) == "" + + +class TestLocalIp: + def test_empty_run_returns_none(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr("macmanager.network._run", lambda *args, **kwargs: "") + assert _local_ip("en0") is None + + def test_strips_whitespace(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr("macmanager.network._run", lambda *args, **kwargs: " 10.0.0.4\n") + assert _local_ip("en0") == "10.0.0.4" + + +class TestWifiInfoDegrades: + """`system_profiler` passa de 5s com frequência na primeira chamada. + O coletor tem que devolver dict vazio para o painel mostrar '—' + em vez de traceback.""" + + @pytest.fixture(autouse=True) + def _clear_wifi_cache(self) -> Iterator[None]: + clear_all() + yield + clear_all() + + def test_empty_profiler_output_returns_empty_dict( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setattr("macmanager.network._run", lambda *args, **kwargs: "") + assert _wifi_info() == {} + + def test_parses_ssid_from_profiler_block(self, monkeypatch: pytest.MonkeyPatch) -> None: + sample = ( + "Current Network Information:\n" + " HomeNet:\n" + " Security: WPA3 Personal\n" + " Channel: 36 (5GHz, 80MHz)\n" + " Signal / Noise: -55 dBm / -92 dBm\n" + " Transmit Rate: 1200\n" + ) + monkeypatch.setattr("macmanager.network._run", lambda *args, **kwargs: sample) + info = _wifi_info() + assert info["ssid"] == "HomeNet" + assert info["security"] == "WPA3 Personal" + assert info["rssi"] == -55 + assert info["tx_rate"] == "1200"