"""Unit tests for the debugpy bootstrap (PLAN.md A18). The real debugger must never bind a port during the suite: each test injects a fake `debugpy` through `sys.modules`, which is exactly what the lazy import in `undercooked.debug` resolves against. """ from __future__ import annotations import logging import sys from typing import Any import pytest from undercooked import debug from undercooked.config import Settings pytestmark = pytest.mark.unit class FakeDebugpy: """Stand-in for the `debugpy` module; records `listen` calls, may fail.""" def __init__(self, error: Exception | None = None) -> None: self.error = error self.calls: list[tuple[Any, dict[str, Any]]] = [] def listen(self, address: Any, **kwargs: Any) -> Any: self.calls.append((address, kwargs)) if self.error is not None: raise self.error return address def install(monkeypatch: pytest.MonkeyPatch, fake: FakeDebugpy | None) -> None: """Put `fake` (or `None`, for "not installed") where `import debugpy` looks.""" monkeypatch.setitem(sys.modules, "debugpy", fake) def test_disabled_is_a_no_op(monkeypatch: pytest.MonkeyPatch) -> None: # `None` in sys.modules makes any `import debugpy` raise, so reaching the # import at all would fail the test instead of silently binding a port. install(monkeypatch, None) assert debug.maybe_start_debugpy(Settings()) is False def test_disabled_never_logs( monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture ) -> None: with caplog.at_level(logging.DEBUG, logger="undercooked.debug"): debug.maybe_start_debugpy(Settings(debugpy=False, debugpy_port=5999)) assert caplog.records == [] def test_enabled_listens_once_without_waiting(monkeypatch: pytest.MonkeyPatch) -> None: fake = FakeDebugpy() install(monkeypatch, fake) assert debug.maybe_start_debugpy(Settings(debugpy=True)) is True assert len(fake.calls) == 1 address, kwargs = fake.calls[0] assert address == (debug.DEBUGPY_BIND_HOST, 5678) # Non-blocking is the whole point: startup must not stall waiting for an editor. assert kwargs.get("wait_for_client", False) is False def test_enabled_honours_a_custom_port(monkeypatch: pytest.MonkeyPatch) -> None: fake = FakeDebugpy() install(monkeypatch, fake) assert debug.maybe_start_debugpy(Settings(debugpy=True, debugpy_port=5999)) is True assert fake.calls[0][0] == (debug.DEBUGPY_BIND_HOST, 5999) def test_enabled_logs_the_endpoint( monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture ) -> None: install(monkeypatch, FakeDebugpy()) with caplog.at_level(logging.INFO, logger="undercooked.debug"): debug.maybe_start_debugpy(Settings(debugpy=True, debugpy_port=5999)) assert "5999" in caplog.text assert "debugpy" in caplog.text.lower() def test_missing_debugpy_does_not_stop_the_server( monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture ) -> None: install(monkeypatch, None) with caplog.at_level(logging.WARNING, logger="undercooked.debug"): assert debug.maybe_start_debugpy(Settings(debugpy=True)) is False assert "not installed" in caplog.text @pytest.mark.parametrize( "error", [ OSError(98, "Address already in use"), RuntimeError("already listening"), ValueError("bad address"), ], ) def test_listen_failures_are_swallowed( monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture, error: Exception, ) -> None: install(monkeypatch, FakeDebugpy(error)) with caplog.at_level(logging.WARNING, logger="undercooked.debug"): assert debug.maybe_start_debugpy(Settings(debugpy=True, debugpy_port=5678)) is False assert "could not listen" in caplog.text def test_logger_is_named_after_the_module() -> None: assert isinstance(debug.logger, logging.Logger) assert debug.logger.name == "undercooked.debug"