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:
@@ -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
|
||||
Reference in New Issue
Block a user