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`.
This commit is contained in:
2026-10-06 01:51:20 -04:00
parent f84c6ebaef
commit 6fd83446ec
43 changed files with 5478 additions and 30 deletions
+161
View File
@@ -0,0 +1,161 @@
"""Shared pytest configuration and fixtures for the Undercooked test suite.
`live_server` is the one fixture that binds a real socket (an ephemeral loopback
port) because the Playwright e2e tests need a URL a browser can load; everything
else stays in-process.
Fixtures that depend on modules created by later phase-01 tasks
(`undercooked.server.app`) import them lazily via `importlib`, so this conftest
keeps working before those modules exist.
"""
from __future__ import annotations
import importlib
import os
import threading
import time
from collections.abc import Callable, Iterator
from pathlib import Path
from typing import Any
import httpx
import pytest
from fastapi.testclient import TestClient
TESTS_DIR = Path(__file__).resolve().parent
FIXTURE_LEVELS_DIR = TESTS_DIR / "fixtures" / "levels"
LIVE_HOST = "127.0.0.1" # loopback only: tests never listen on a routable interface
def _as_env_value(value: object) -> str:
"""Render a Python override value the way `load_settings` would read it."""
if isinstance(value, bool):
return "1" if value else "0"
if isinstance(value, str | Path):
return str(value)
if isinstance(value, list | tuple):
return ",".join(str(item) for item in value)
return str(value)
@pytest.fixture(scope="session", autouse=True)
def isolated_levels_dir() -> Iterator[None]:
"""Point `UNDERCOOKED_LEVELS_DIR` at an empty fixture dir for every test.
No test may read the real `levels/` content implicitly; the shipped
content pipeline only arrives with phase 02.
"""
FIXTURE_LEVELS_DIR.mkdir(parents=True, exist_ok=True)
previous = os.environ.get("UNDERCOOKED_LEVELS_DIR")
os.environ["UNDERCOOKED_LEVELS_DIR"] = str(FIXTURE_LEVELS_DIR)
yield
if previous is None:
os.environ.pop("UNDERCOOKED_LEVELS_DIR", None)
else:
os.environ["UNDERCOOKED_LEVELS_DIR"] = previous
def _wait_for_health(url: str, thread: threading.Thread) -> None:
"""Poll `/healthz` until it answers 200, failing fast if the server thread died."""
timeout = float(os.environ.get("UNDERCOOKED_TEST_SOCKET_TIMEOUT", "30"))
deadline = time.monotonic() + timeout
probe = httpx.Client(timeout=1.0, trust_env=False)
try:
while time.monotonic() < deadline:
try:
if probe.get(f"{url}/healthz").status_code == 200:
return
except httpx.HTTPError:
pass
thread.join(timeout=0.1)
if not thread.is_alive():
raise RuntimeError(f"live server on {url} died during startup")
finally:
probe.close()
raise RuntimeError(f"live server on {url} never became healthy")
@pytest.fixture(scope="session")
def live_server() -> Iterator[str]:
"""Base URL of a real uvicorn server, started once for the session.
The Playwright e2e tests need an address a browser can actually load, which
the in-process `TestClient` cannot provide. uvicorn binds the socket before
the serving thread starts, so the ephemeral port is known exactly (no log
scraping, no collisions between concurrent runs), and shutdown is requested
through `should_exit` so the lifespan stops cleanly.
"""
uvicorn = importlib.import_module("uvicorn")
app_module = importlib.import_module("undercooked.server.app")
config = uvicorn.Config(
app_module.create_app(),
host=LIVE_HOST,
port=0,
log_level="warning",
lifespan="on",
)
sock = config.bind_socket()
url = f"http://{LIVE_HOST}:{sock.getsockname()[1]}"
server = uvicorn.Server(config)
thread = threading.Thread(
target=server.run, args=([sock],), name="undercooked-live", daemon=True
)
thread.start()
_wait_for_health(url, thread)
try:
yield url
finally:
server.should_exit = True
thread.join(timeout=15)
if thread.is_alive(): # pragma: no cover - only if uvicorn stops cooperating
pytest.fail("live server did not shut down within 15 s")
@pytest.fixture
def random_seed() -> int:
"""Deterministic seed for sim/replay tests (determinism is a contract)."""
return 0x5EED_0401
@pytest.fixture
def make_settings() -> Callable[..., Any]:
"""Build `undercooked.config.Settings` from field-name overrides.
Overrides are translated into their env vars using the authoritative
`config.ENV_KEYS` contract (field name -> `UNDERCOOKED_*` / `DEBUGPY*`), and
`load_settings(env)` is called with a copy of the process environment that
has those vars stripped, so ambient env and `.env` files cannot leak into
tests.
"""
def _make(**overrides: object) -> Any:
config = importlib.import_module("undercooked.config")
env: dict[str, str] = {
key: value
for key, value in os.environ.items()
if not key.startswith(("UNDERCOOKED_", "DEBUGPY"))
}
for name, value in overrides.items():
if name not in config.ENV_KEYS:
pytest.fail(f"unknown settings field override: {name!r}")
env[config.ENV_KEYS[name]] = _as_env_value(value)
return config.load_settings(env)
return _make
@pytest.fixture
def app() -> Any:
"""Fresh FastAPI application built through `undercooked.server.app.create_app()`."""
module = importlib.import_module("undercooked.server.app")
return module.create_app()
@pytest.fixture
def client(app: Any) -> Iterator[TestClient]:
"""`TestClient` bound to the `app` fixture, running startup/shutdown."""
with TestClient(app) as test_client:
yield test_client