"""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 this level-free fixture dir for every test. No test may read the real `levels/` content implicitly. Since phase 02 task 04 `create_app()` also needs a recipe book, and the dir carries a committed minimal `recipes.yaml` (zero levels — a legal campaign) so fixture-isolated apps still boot; tests that need real levels build one under `tmp_path` or point explicitly at `levels/`. """ 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