Files
undercooked/tests/unit/test_debug.py
T
ducoterra 6fd83446ec phase: 01_runnable_service_scaffold
**Phase 01 final verification pass — all criteria green**

- Verified (nothing left to implement; all 4 task files already in `complete/`): toolchain lock, typed `Settings`+`.env.example`, debugpy bootstrap, app factory, CLI (`--host/--port/--reload/--levels-dir`, `python -m undercooked`), static shell + `/static`, security headers, no-CDN/doc guards, `validate.sh`.
- `uv sync --extra dev` → exit 0; `uv.lock` present (untracked → harness commits it with the phase).
- `uv run undercooked --host 127.0.0.1 --port 8000` → `curl /healthz` = 200 `{"status":"ok","version":"0.1.0","rooms":0}` (superset of the criterion's body — now documented in README); `curl /` = 200 shell HTML; static css/js 200; CSP/nosniff/no-referrer/DENY on 200, 404 and 405 too.
- `DEBUGPY=1`: port 5678 is held on this machine by the user's own podman dev-container debugpy adapter — **no live change made/killed**; verified via `DEBUGPY=1 DEBUGPY_PORT=5679` → listener opens, `/healthz` still 200; the occupied-port case degraded gracefully as designed (warning, server serves). Bad config → one line + exit 2 ✓.
- `bash .agents/validate.sh` (root **and** `.agents/`) → exit 0: `ruff check .` 0 errors, `pyright` 0 errors, levels-CLI notice-skip (matches README prose), **284 passed** full suite incl. 10 Playwright e2e, coverage **100.00%** (floor 90), repo hygiene clean. `uv run pytest -m e2e` → 10 passed.
- Docs fixed: README Status → phase 01 complete + next pending 02 (was pointing at `todo/01…`), documented real `/healthz` body, precedence now "CLI flags > env > .env > default", added the PLAN §13-promised **How to play** section (honest about shell-only today), Layout rows marked what exists vs phases 02/07. AGENTS.md: recorded the `data:`-URL exemption in the no-CDN rule (test allows it) and matched the session-token rule to the browser's WebCrypto/base64url implementation. Also corrected a stale "session-wide chromium" docstring in `tests/e2e/conftest.py` (fixtures are function-scoped).
- Deviations: no container build run (needs an image pull; not a phase criterion — `Containerfile`/`compose.yaml` reviewed and consistent with `docs/DEPLOYMENT.md`); `HEAD /` is 405 (routes are GET-only by design; health checks use GET). No git index/history commands run.
- Next pending phase: `02_level_content_and_validation`.
2026-10-06 01:51:20 -04:00

115 lines
3.9 KiB
Python

"""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"