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
|
||||
@@ -0,0 +1 @@
|
||||
Playwright browser suites, one per user story (`test_usNN_*.py`, marker `e2e`).
|
||||
@@ -0,0 +1,66 @@
|
||||
"""Playwright fixtures for the browser suites in `tests/e2e/`.
|
||||
|
||||
The project depends on the `playwright` package, not on `pytest-playwright`, so
|
||||
the three fixtures the plugin would provide are defined here — a headless chromium,
|
||||
a fresh context and a fresh page, all function-scoped (see `browser` for why the scope
|
||||
is load-bearing). A fresh context per test matters too: `sessionStorage` (the anonymous
|
||||
`uc.token`, `uc.lastCode`) is scoped to it, so tests cannot observe each other's
|
||||
identity.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import pytest
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Iterator
|
||||
|
||||
from playwright.sync_api import Browser, BrowserContext, Page, ViewportSize
|
||||
|
||||
# The default desktop viewport; story tests that care about small screens call
|
||||
# `page.set_viewport_size` before navigating.
|
||||
VIEWPORT: ViewportSize = {"width": 1280, "height": 720}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def browser() -> Iterator[Browser]:
|
||||
"""A headless chromium, live for exactly one test.
|
||||
|
||||
Function scope is load-bearing, not shyness about startup time. The
|
||||
*synchronous* Playwright API drives the browser from an asyncio event loop it
|
||||
owns in the main thread, and that loop stays flagged as running for as long as
|
||||
`sync_playwright()` is open — so a session- or module-scoped fixture makes every
|
||||
later `async def` test in the run die with
|
||||
`RuntimeError: Runner.run() cannot be called from a running event loop`
|
||||
(pytest-asyncio, `asyncio_mode = "auto"`). Closing the whole stack inside the
|
||||
test leaves the main thread clean for whatever runs next.
|
||||
"""
|
||||
with sync_playwright() as playwright:
|
||||
launched = playwright.chromium.launch(headless=True)
|
||||
try:
|
||||
yield launched
|
||||
finally:
|
||||
launched.close()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def context(browser: Browser) -> Iterator[BrowserContext]:
|
||||
"""A brand-new browser context per test: isolated storage and cookies."""
|
||||
created = browser.new_context(viewport=VIEWPORT)
|
||||
try:
|
||||
yield created
|
||||
finally:
|
||||
created.close()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def page(context: BrowserContext) -> Iterator[Page]:
|
||||
"""A tab inside `context`, closed after the test."""
|
||||
opened = context.new_page()
|
||||
try:
|
||||
yield opened
|
||||
finally:
|
||||
opened.close()
|
||||
@@ -0,0 +1,174 @@
|
||||
"""US-01 (landing surface) e2e: a real browser loads the shell, cleanly.
|
||||
|
||||
Phase 01 has no gameplay, so this suite asserts only what a browser can prove and
|
||||
`TestClient` cannot: the document parses, the ES module runs without a single
|
||||
console error, the lobby controls are reachable, and — the point of PLAN A12 —
|
||||
loading and clicking the page makes requests to *nothing* but this process.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import pytest
|
||||
from playwright.sync_api import expect
|
||||
|
||||
from undercooked import __version__
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from playwright.sync_api import Page
|
||||
|
||||
pytestmark = pytest.mark.e2e
|
||||
|
||||
# Strings a player sees, kept in sync with app/static/index.html and
|
||||
# app/static/js/app.js by hand: changing one must be a deliberate act.
|
||||
READY = "Pick a kitchen to start."
|
||||
NOT_IMPLEMENTED = "Not implemented until phase 05."
|
||||
SHORT_CODE = "A room code is 5 characters."
|
||||
TOKEN_PATTERN = re.compile(r"^[A-Za-z0-9_-]{20,}$")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def console_errors(page: Page) -> list[str]:
|
||||
"""Console errors and uncaught exceptions raised while the page runs (empty = good)."""
|
||||
errors: list[str] = []
|
||||
page.on(
|
||||
"console",
|
||||
lambda message: errors.append(f"{message.type}: {message.text}")
|
||||
if message.type == "error"
|
||||
else None,
|
||||
)
|
||||
page.on("pageerror", lambda exception: errors.append(f"pageerror: {exception}"))
|
||||
return errors
|
||||
|
||||
|
||||
def open_shell(page: Page, live_server: str) -> None:
|
||||
"""Load the lobby and wait for its one module request to settle."""
|
||||
page.goto(f"{live_server}/", wait_until="networkidle")
|
||||
|
||||
|
||||
def test_the_shell_loads_without_console_errors(
|
||||
page: Page, live_server: str, console_errors: list[str]
|
||||
) -> None:
|
||||
open_shell(page, live_server)
|
||||
assert page.title() == "Undercooked"
|
||||
expect(page.get_by_role("heading", name="Kitchen lobby")).to_be_visible()
|
||||
expect(page.get_by_role("button", name="Create lobby")).to_be_visible()
|
||||
expect(page.locator("#status")).to_have_text(READY)
|
||||
assert console_errors == []
|
||||
|
||||
|
||||
def test_the_lobby_is_readable_and_zoomable(page: Page, live_server: str) -> None:
|
||||
"""Viewport meta + a single fluid column: the shell works on a phone (PLAN A19)."""
|
||||
page.set_viewport_size({"width": 360, "height": 640})
|
||||
open_shell(page, live_server)
|
||||
assert page.evaluate(
|
||||
"document.querySelector('meta[name=viewport]').content"
|
||||
) == "width=device-width,initial-scale=1,viewport-fit=cover"
|
||||
for selector in ("#create-lobby", "#join-code"):
|
||||
box = page.locator(selector).bounding_box()
|
||||
assert box is not None and box["width"] >= 44 and box["height"] >= 44, (selector, box)
|
||||
# Nothing sticks out of the 360 px viewport: no horizontal scrolling.
|
||||
assert page.evaluate("document.documentElement.scrollWidth") <= 360
|
||||
|
||||
|
||||
def test_create_and_join_explain_that_the_phase_is_missing(
|
||||
page: Page, live_server: str, console_errors: list[str]
|
||||
) -> None:
|
||||
open_shell(page, live_server)
|
||||
status = page.locator("#status")
|
||||
|
||||
page.get_by_role("button", name="Create lobby").click()
|
||||
expect(status).to_have_text(NOT_IMPLEMENTED)
|
||||
|
||||
page.locator("#join-code").press_sequentially("abcde")
|
||||
page.get_by_role("button", name="Join").click()
|
||||
expect(status).to_have_text(NOT_IMPLEMENTED)
|
||||
# The code is normalised for the player: uppercase, five characters.
|
||||
assert page.locator("#join-code").input_value() == "ABCDE"
|
||||
assert console_errors == []
|
||||
|
||||
|
||||
def test_a_short_code_is_rejected_before_any_network_use(page: Page, live_server: str) -> None:
|
||||
open_shell(page, live_server)
|
||||
page.locator("#join-code").press_sequentially("ab")
|
||||
page.get_by_role("button", name="Join").click()
|
||||
expect(page.locator("#status")).to_have_text(SHORT_CODE)
|
||||
# The field refuses a sixth character rather than letting it reach the server.
|
||||
page.locator("#join-code").fill("")
|
||||
page.locator("#join-code").press_sequentially("abcdef")
|
||||
assert page.locator("#join-code").input_value() == "abcde"
|
||||
|
||||
|
||||
def test_the_only_requests_the_page_makes_are_ours(page: Page, live_server: str) -> None:
|
||||
"""PLAN A12 in a real browser: no CDN, no web font, no beacon, no analytics."""
|
||||
requested: list[str] = []
|
||||
page.on("request", lambda request: requested.append(request.url))
|
||||
open_shell(page, live_server)
|
||||
page.get_by_role("button", name="Create lobby").click()
|
||||
|
||||
assert [url for url in requested if not url.startswith(live_server)] == []
|
||||
# …and the check above is not vacuous: the shell really did fetch its own assets.
|
||||
assert sorted(url for url in requested if url != f"{live_server}/") == [
|
||||
f"{live_server}/static/css/base.css",
|
||||
f"{live_server}/static/js/app.js",
|
||||
]
|
||||
|
||||
|
||||
def test_the_document_arrives_locked_down(page: Page, live_server: str) -> None:
|
||||
response = page.goto(f"{live_server}/")
|
||||
assert response is not None
|
||||
assert response.status == 200
|
||||
assert response.headers["content-security-policy"].startswith("default-src 'self';")
|
||||
assert "frame-ancestors 'none'" in response.headers["content-security-policy"]
|
||||
assert response.headers["x-content-type-options"] == "nosniff"
|
||||
assert response.headers["x-frame-options"] == "DENY"
|
||||
|
||||
|
||||
def test_the_page_can_still_call_its_own_health_endpoint(
|
||||
page: Page, live_server: str, console_errors: list[str]
|
||||
) -> None:
|
||||
"""`connect-src 'self'` must allow same-origin fetches, or phase 05 cannot work."""
|
||||
open_shell(page, live_server)
|
||||
result = page.evaluate(
|
||||
"""async () => {
|
||||
const response = await fetch('/healthz');
|
||||
return {status: response.status, body: await response.json()};
|
||||
}"""
|
||||
)
|
||||
assert result == {"status": 200, "body": {"status": "ok", "version": __version__, "rooms": 0}}
|
||||
assert console_errors == []
|
||||
|
||||
|
||||
def test_one_anonymous_token_per_tab_and_no_cookies(
|
||||
page: Page, live_server: str
|
||||
) -> None:
|
||||
"""Identity is a random per-tab token (PLAN A16); a reload must not mint another."""
|
||||
open_shell(page, live_server)
|
||||
token = page.evaluate("sessionStorage.getItem('uc.token')")
|
||||
assert isinstance(token, str), token
|
||||
assert TOKEN_PATTERN.match(token), token
|
||||
|
||||
open_shell(page, live_server)
|
||||
assert page.evaluate("sessionStorage.getItem('uc.token')") == token
|
||||
assert page.context.cookies() == [] # anonymous by design: nothing identifying
|
||||
|
||||
|
||||
def test_the_last_code_is_remembered_for_the_next_visit(page: Page, live_server: str) -> None:
|
||||
open_shell(page, live_server)
|
||||
page.locator("#join-code").press_sequentially("k7xyz")
|
||||
page.get_by_role("button", name="Join").click()
|
||||
expect(page.locator("#status")).to_have_text(NOT_IMPLEMENTED)
|
||||
|
||||
open_shell(page, live_server)
|
||||
assert page.evaluate("sessionStorage.getItem('uc.lastCode')") == "K7XYZ"
|
||||
assert page.locator("#join-code").input_value() == "K7XYZ"
|
||||
|
||||
|
||||
def test_the_canvas_stays_hidden_until_the_renderer_exists(page: Page, live_server: str) -> None:
|
||||
"""Phase 07 unhides `#kcanvas`; until then it is out of the layout and a11y tree."""
|
||||
open_shell(page, live_server)
|
||||
canvas = page.locator("#kcanvas")
|
||||
expect(canvas).to_be_hidden()
|
||||
assert canvas.get_attribute("aria-hidden") == "true"
|
||||
Vendored
@@ -0,0 +1 @@
|
||||
In-process integration tests: FastAPI app boot, HTTP routes, rooms, and the WebSocket protocol.
|
||||
@@ -0,0 +1,319 @@
|
||||
"""Integration: the app boots through `create_app()` and serves the real surface.
|
||||
|
||||
Covers the HTTP contract of phase 01 task 03 — `/healthz`, the client shell at
|
||||
`/`, the `/static` mount, path-traversal refusal, the startup hook, and the
|
||||
security headers on every kind of response (PLAN.md §4, §11, A12).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
import importlib
|
||||
import logging
|
||||
import sys
|
||||
from collections.abc import Iterator
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from undercooked import __version__
|
||||
from undercooked.config import ENV_KEYS, ConfigError, Settings, reset_settings_cache
|
||||
from undercooked.server import app as app_module
|
||||
from undercooked.server.app import (
|
||||
LOGGED_SETTINGS,
|
||||
STATIC_DIR,
|
||||
create_app,
|
||||
get_app,
|
||||
resolve_static_dir,
|
||||
settings_log_line,
|
||||
)
|
||||
from undercooked.server.headers import SECURITY_HEADERS
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
|
||||
from starlette.types import Message
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
APP_LOGGER = "undercooked.server.app"
|
||||
|
||||
#: One route of every kind the app answers with: HTML page, JSON endpoint,
|
||||
#: static asset, and the 404 that catches everything else.
|
||||
ROUTE_KINDS = ("/", "/healthz", "/static/css/base.css", "/definitely-not-a-route")
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def fresh_settings_cache() -> Iterator[None]:
|
||||
"""No test may inherit a cached `Settings` (or a `.env` read) from another."""
|
||||
reset_settings_cache()
|
||||
yield
|
||||
reset_settings_cache()
|
||||
|
||||
|
||||
async def _asgi_get(application: FastAPI, target: str) -> tuple[int, bytes]:
|
||||
"""GET ``target`` against the ASGI app verbatim; return `(status, body)`.
|
||||
|
||||
httpx normalizes dot-segments, so a traversal attempt written through
|
||||
`TestClient` never reaches the static mount as `..`. Driving the ASGI app by
|
||||
hand is how a raw client (curl, a browser proxy, a hostile socket) reaches it.
|
||||
"""
|
||||
scope: dict[str, Any] = {
|
||||
"type": "http",
|
||||
"asgi": {"version": "3.0", "spec_version": "2.3"},
|
||||
"http_version": "1.1",
|
||||
"method": "GET",
|
||||
"scheme": "http",
|
||||
"path": target,
|
||||
"raw_path": target.encode("ascii"),
|
||||
"query_string": b"",
|
||||
"root_path": "",
|
||||
"headers": [(b"host", b"testserver"), (b"accept", b"*/*")],
|
||||
"client": ("127.0.0.1", 54321),
|
||||
"server": ("testserver", 80),
|
||||
}
|
||||
sent: list[Message] = []
|
||||
|
||||
async def receive() -> dict[str, Any]:
|
||||
return {"type": "http.request", "body": b"", "more_body": False}
|
||||
|
||||
async def send(message: Message) -> None:
|
||||
sent.append(message)
|
||||
|
||||
await application(scope, receive, send)
|
||||
start = next(message for message in sent if message["type"] == "http.response.start")
|
||||
body = b"".join(
|
||||
message.get("body", b"") for message in sent if message["type"] == "http.response.body"
|
||||
)
|
||||
return int(start["status"]), body
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------- /healthz
|
||||
|
||||
|
||||
def test_healthz_is_ok_with_the_expected_shape(client: TestClient) -> None:
|
||||
response = client.get("/healthz")
|
||||
assert response.status_code == 200
|
||||
assert response.headers["content-type"].startswith("application/json")
|
||||
assert response.json() == {"status": "ok", "version": __version__, "rooms": 0}
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------- client shell
|
||||
|
||||
|
||||
def test_root_serves_the_shell_html(client: TestClient) -> None:
|
||||
response = client.get("/")
|
||||
assert response.status_code == 200
|
||||
assert response.headers["content-type"].startswith("text/html")
|
||||
assert int(response.headers["content-length"]) > 500 # streamed from the file
|
||||
html = response.text
|
||||
assert 'src="/static/js/app.js"' in html
|
||||
assert 'href="/static/css/base.css"' in html
|
||||
assert 'id="screen-lobby"' in html
|
||||
assert 'aria-live="polite"' in html
|
||||
assert (
|
||||
'name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover"' in html
|
||||
)
|
||||
|
||||
|
||||
def test_shell_offers_the_lobby_controls(client: TestClient) -> None:
|
||||
html = client.get("/").text
|
||||
assert 'id="create-lobby"' in html
|
||||
assert 'id="join-form"' in html
|
||||
assert 'id="join-code"' in html
|
||||
assert 'inputmode="text"' in html
|
||||
assert 'autocapitalize="characters"' in html
|
||||
assert 'size="5"' in html # five characters wide: no 20-char intrinsic box
|
||||
assert 'maxlength="5"' in html
|
||||
assert 'for="join-code"' in html # the code input has a real label
|
||||
|
||||
|
||||
def test_static_css_and_js_are_served_with_real_mime_types(client: TestClient) -> None:
|
||||
css = client.get("/static/css/base.css")
|
||||
assert css.status_code == 200
|
||||
assert css.headers["content-type"].startswith("text/css")
|
||||
assert "--uc-hit: 44px" in css.text # the 44px hit-target contract lives in CSS
|
||||
|
||||
script = client.get("/static/js/app.js")
|
||||
assert script.status_code == 200
|
||||
assert script.headers["content-type"].startswith("text/javascript")
|
||||
assert "uc.token" in script.text
|
||||
|
||||
|
||||
def test_unknown_path_is_404(client: TestClient) -> None:
|
||||
assert client.get("/definitely-not-a-route").status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.parametrize("path", ROUTE_KINDS)
|
||||
def test_every_response_carries_the_security_headers(client: TestClient, path: str) -> None:
|
||||
response = client.get(path)
|
||||
for header, value in SECURITY_HEADERS.items():
|
||||
assert response.headers[header] == value, f"{path} is missing {header}"
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------- traversal
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"path",
|
||||
[
|
||||
"/static/%2e%2e/pyproject.toml", # encoded ".."
|
||||
"/static/%2e%2e%2fpyproject.toml", # encoded "../"
|
||||
"/static/./../../pyproject.toml",
|
||||
"/static/README.md",
|
||||
"/static/.env",
|
||||
],
|
||||
)
|
||||
def test_static_mount_cannot_escape_its_directory(client: TestClient, path: str) -> None:
|
||||
response = client.get(path)
|
||||
assert response.status_code in (400, 404)
|
||||
assert "requires-python" not in response.text
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"target",
|
||||
[
|
||||
"/static/../pyproject.toml", # what a raw client sends: no normalization
|
||||
"/static/../../../etc/passwd",
|
||||
"/static/%2e%2e/%2e%2e/pyproject.toml",
|
||||
],
|
||||
)
|
||||
async def test_asgi_layer_refuses_a_literal_traversal(target: str) -> None:
|
||||
status, body = await _asgi_get(create_app(Settings()), target)
|
||||
assert status in (400, 404)
|
||||
assert b"requires-python" not in body
|
||||
assert b"root:" not in body
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------- settings wiring
|
||||
|
||||
|
||||
def test_app_state_holds_the_settings_it_was_built_with() -> None:
|
||||
settings = Settings(max_players=2, port=8137, tick_hz=30, snapshot_hz=30)
|
||||
application = create_app(settings)
|
||||
assert application.state.settings is settings
|
||||
|
||||
|
||||
def test_overridden_settings_are_visible_through_a_live_request(
|
||||
make_settings: Callable[..., Settings],
|
||||
) -> None:
|
||||
application = create_app(make_settings(max_players=1, max_rooms=3))
|
||||
with TestClient(application) as test_client:
|
||||
assert test_client.get("/healthz").status_code == 200
|
||||
assert application.state.settings.max_players == 1
|
||||
assert application.state.settings.max_rooms == 3
|
||||
|
||||
|
||||
def test_static_dir_override_is_served(tmp_path: Path) -> None:
|
||||
(tmp_path / "probe.txt").write_text("from the override", encoding="utf-8")
|
||||
settings = Settings(static_dir=tmp_path)
|
||||
assert resolve_static_dir(settings) == tmp_path
|
||||
with TestClient(create_app(settings)) as test_client:
|
||||
assert test_client.get("/static/probe.txt").text == "from the override"
|
||||
|
||||
|
||||
def test_default_static_dir_is_the_repository_shell() -> None:
|
||||
assert STATIC_DIR == REPO_ROOT / "app" / "static"
|
||||
assert resolve_static_dir(Settings()) == STATIC_DIR
|
||||
assert (STATIC_DIR / "index.html").is_file()
|
||||
|
||||
|
||||
def test_missing_static_directory_fails_at_startup(tmp_path: Path) -> None:
|
||||
with pytest.raises(ConfigError, match=r"static directory .* does not exist"):
|
||||
create_app(Settings(static_dir=tmp_path / "nope"))
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------- startup hook
|
||||
|
||||
|
||||
def test_startup_logs_the_resolved_settings_once(caplog: pytest.LogCaptureFixture) -> None:
|
||||
with (
|
||||
caplog.at_level(logging.INFO, logger=APP_LOGGER),
|
||||
TestClient(create_app(Settings())) as test_client,
|
||||
):
|
||||
assert test_client.get("/healthz").status_code == 200
|
||||
assert caplog.text.count("starting") == 1
|
||||
assert "port=8000" in caplog.text
|
||||
assert "tick_hz=20" in caplog.text
|
||||
assert "stopped" in caplog.text # the shutdown half of the lifespan
|
||||
|
||||
|
||||
def test_startup_log_never_dumps_the_environment(
|
||||
monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""The hook logs resolved *values*, never variable names or a `.env` file."""
|
||||
monkeypatch.delenv("UNDERCOOKED_PORT", raising=False)
|
||||
monkeypatch.setenv("UNDERCOOKED_SIM_LATENCY_MS", "5")
|
||||
reset_settings_cache()
|
||||
# settings straight from the environment, exactly as `uv run undercooked` does
|
||||
with caplog.at_level(logging.INFO, logger=APP_LOGGER), TestClient(create_app()):
|
||||
pass
|
||||
text = caplog.text
|
||||
assert "port=8000" in text
|
||||
assert "sim_latency_ms=5" in text
|
||||
assert not any(name in text for name in ENV_KEYS.values())
|
||||
assert ".env" not in text
|
||||
|
||||
|
||||
def test_settings_log_line_is_a_fixed_vocabulary_summary() -> None:
|
||||
line = settings_log_line(Settings())
|
||||
assert line == (
|
||||
"host=127.0.0.1 port=8000 tick_hz=20 snapshot_hz=20 max_players=4 max_rooms=200 "
|
||||
"levels_dir=levels lobby_idle_timeout_s=900.0 guest_idle_timeout_s=45.0 "
|
||||
"max_message_bytes=4096 max_inputs_per_s=40 sim_latency_ms=0 "
|
||||
"allowed_origins=() debugpy=False"
|
||||
)
|
||||
names = {field.name for field in dataclasses.fields(Settings)}
|
||||
assert set(LOGGED_SETTINGS) <= names
|
||||
|
||||
|
||||
def test_startup_hook_passes_the_app_settings_to_the_debugpy_bootstrap(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
calls: list[Settings] = []
|
||||
monkeypatch.setattr(
|
||||
app_module, "maybe_start_debugpy", lambda settings: calls.append(settings) or False
|
||||
)
|
||||
settings = Settings(debugpy=True, debugpy_port=5999)
|
||||
with TestClient(create_app(settings)):
|
||||
pass
|
||||
assert calls == [settings]
|
||||
|
||||
|
||||
def test_booting_the_app_with_debugpy_disabled_never_imports_it(
|
||||
monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""A `None` entry makes any real `import debugpy` raise, so a stray listener
|
||||
would fail loudly instead of binding a port during the suite."""
|
||||
monkeypatch.setitem(sys.modules, "debugpy", None)
|
||||
with caplog.at_level(logging.DEBUG, logger="undercooked.debug"), TestClient(
|
||||
create_app(Settings())
|
||||
):
|
||||
pass
|
||||
assert caplog.records == []
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------- module `app`
|
||||
|
||||
|
||||
def test_module_level_app_is_lazy_and_cached() -> None:
|
||||
module = importlib.import_module("undercooked.server.app")
|
||||
assert module.app is module.app # built once, then cached
|
||||
assert isinstance(module.app, FastAPI)
|
||||
assert get_app() is module.app
|
||||
|
||||
|
||||
def test_unknown_module_attribute_raises() -> None:
|
||||
with pytest.raises(AttributeError, match="has no attribute"):
|
||||
_ = app_module.not_a_real_attribute
|
||||
|
||||
|
||||
def test_docs_endpoints_are_disabled_because_they_load_a_cdn() -> None:
|
||||
"""A12: FastAPI renders /docs and /redoc with assets from a public CDN."""
|
||||
with TestClient(create_app(Settings())) as test_client:
|
||||
for path in ("/docs", "/redoc", "/openapi.json"):
|
||||
assert test_client.get(path).status_code == 404
|
||||
@@ -0,0 +1 @@
|
||||
Headless determinism and replay tests against the pure simulation kernel (`sim/`).
|
||||
@@ -0,0 +1 @@
|
||||
Pure, fast unit tests: config parsing, helpers, and other no-I/O logic.
|
||||
@@ -0,0 +1,471 @@
|
||||
"""Unit tests for `undercooked.config` (PLAN.md A17): defaults, overrides, validation.
|
||||
|
||||
Everything here calls `load_settings({...})` with an explicit mapping wherever
|
||||
possible, so a developer's local `.env` can never change what a test asserts.
|
||||
The `.env` / cache behaviour is exercised separately, in an isolated directory.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from collections.abc import Callable, Iterator
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from undercooked import config
|
||||
from undercooked.config import (
|
||||
ENV_KEYS,
|
||||
ConfigError,
|
||||
Settings,
|
||||
apply_cli_overrides,
|
||||
get_settings,
|
||||
load_settings,
|
||||
reset_settings_cache,
|
||||
)
|
||||
|
||||
pytestmark = pytest.mark.unit
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def pristine_env() -> Iterator[None]:
|
||||
"""Undo env/cache side effects - `load_dotenv` mutates `os.environ` in place."""
|
||||
saved = dict(os.environ)
|
||||
reset_settings_cache()
|
||||
yield
|
||||
os.environ.clear()
|
||||
os.environ.update(saved)
|
||||
reset_settings_cache()
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------- defaults
|
||||
|
||||
|
||||
def test_defaults_match_the_documented_table() -> None:
|
||||
settings = load_settings({})
|
||||
assert settings == Settings()
|
||||
assert settings.host == "127.0.0.1"
|
||||
assert settings.port == 8000
|
||||
assert settings.tick_hz == 20
|
||||
assert settings.snapshot_hz == 20
|
||||
assert settings.max_players == 4
|
||||
assert settings.max_rooms == 200
|
||||
assert settings.levels_dir == Path("levels")
|
||||
assert settings.static_dir is None # derived from the package layout, see server.app
|
||||
assert settings.lobby_idle_timeout_s == 900.0
|
||||
assert settings.guest_idle_timeout_s == 45.0
|
||||
assert settings.max_message_bytes == 4096
|
||||
assert settings.max_inputs_per_s == 40
|
||||
assert settings.sim_latency_ms == 0
|
||||
assert settings.debugpy is False
|
||||
assert settings.debugpy_port == 5678
|
||||
assert settings.allowed_origins == ()
|
||||
|
||||
|
||||
def test_env_keys_cover_exactly_the_settings_fields() -> None:
|
||||
assert set(ENV_KEYS) == {field.name for field in dataclasses.fields(Settings)}
|
||||
|
||||
|
||||
def test_settings_is_frozen_and_uses_slots() -> None:
|
||||
settings = Settings()
|
||||
with pytest.raises(dataclasses.FrozenInstanceError):
|
||||
settings.port = 1234 # type: ignore[misc]
|
||||
assert not hasattr(settings, "__dict__")
|
||||
|
||||
|
||||
def test_config_error_is_a_value_error() -> None:
|
||||
assert issubclass(ConfigError, ValueError)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------- overrides
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("field", "raw", "expected"),
|
||||
[
|
||||
("host", "localhost", "localhost"),
|
||||
("port", "8123", 8123),
|
||||
("tick_hz", "30", 30),
|
||||
("snapshot_hz", "15", 15),
|
||||
("max_players", "2", 2),
|
||||
("max_rooms", "1", 1),
|
||||
("lobby_idle_timeout_s", "60", 60.0),
|
||||
("guest_idle_timeout_s", "7.5", 7.5),
|
||||
("max_message_bytes", "8192", 8192),
|
||||
("max_inputs_per_s", "60", 60),
|
||||
("sim_latency_ms", "150", 150),
|
||||
("debugpy_port", "5679", 5679),
|
||||
],
|
||||
)
|
||||
def test_single_variable_override(field: str, raw: str, expected: object) -> None:
|
||||
settings = load_settings({ENV_KEYS[field]: raw})
|
||||
assert getattr(settings, field) == expected
|
||||
assert settings.levels_dir == Path("levels") # untouched fields keep their default
|
||||
|
||||
|
||||
@pytest.mark.parametrize("raw", ["1", "true", "TRUE", "yes", "on"])
|
||||
def test_debugpy_truthy_values(raw: str) -> None:
|
||||
assert load_settings({"DEBUGPY": raw}).debugpy is True
|
||||
|
||||
|
||||
@pytest.mark.parametrize("raw", ["0", "false", "False", "no", "off"])
|
||||
def test_debugpy_falsy_values(raw: str) -> None:
|
||||
assert load_settings({"DEBUGPY": raw}).debugpy is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("raw", "expected"),
|
||||
[
|
||||
("", ()),
|
||||
(" ", ()),
|
||||
("http://localhost:3000", ("http://localhost:3000",)),
|
||||
("http://a , http://b", ("http://a", "http://b")),
|
||||
("http://a,,http://b,", ("http://a", "http://b")),
|
||||
(",,", ()),
|
||||
],
|
||||
)
|
||||
def test_allowed_origins_is_comma_split(raw: str, expected: tuple[str, ...]) -> None:
|
||||
assert load_settings({ENV_KEYS["allowed_origins"]: raw}).allowed_origins == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("raw", "expected"),
|
||||
[
|
||||
("levels", Path("levels")),
|
||||
("levels/", Path("levels")),
|
||||
("/srv/undercooked/levels", Path("/srv/undercooked/levels")),
|
||||
("~/kitchens", Path.home() / "kitchens"),
|
||||
("./kitchens", Path("kitchens")),
|
||||
],
|
||||
)
|
||||
def test_levels_dir_path_handling(raw: str, expected: Path) -> None:
|
||||
assert load_settings({ENV_KEYS["levels_dir"]: raw}).levels_dir == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("raw", "expected"),
|
||||
[
|
||||
("/app/app/static", Path("/app/app/static")),
|
||||
("~/static", Path.home() / "static"),
|
||||
("static/", Path("static")),
|
||||
("", None),
|
||||
(" ", None),
|
||||
],
|
||||
)
|
||||
def test_static_dir_is_optional_and_expanded(raw: str, expected: Path | None) -> None:
|
||||
"""An unset `UNDERCOOKED_STATIC_DIR` means "derive it from the package"."""
|
||||
assert load_settings({ENV_KEYS["static_dir"]: raw}).static_dir == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"field", ["port", "tick_hz", "lobby_idle_timeout_s", "host", "allowed_origins"]
|
||||
)
|
||||
@pytest.mark.parametrize("blank", ["", " ", "\t"])
|
||||
def test_blank_value_counts_as_unset(field: str, blank: str) -> None:
|
||||
assert load_settings({ENV_KEYS[field]: blank}) == Settings()
|
||||
|
||||
|
||||
def test_surrounding_whitespace_is_tolerated() -> None:
|
||||
settings = load_settings({ENV_KEYS["port"]: " 8123 ", ENV_KEYS["host"]: "\nlocalhost\t"})
|
||||
assert (settings.port, settings.host) == (8123, "localhost")
|
||||
|
||||
|
||||
def test_snapshot_hz_may_equal_tick_hz_but_not_exceed_it() -> None:
|
||||
both_ten = load_settings({ENV_KEYS["tick_hz"]: "10", ENV_KEYS["snapshot_hz"]: "10"})
|
||||
assert both_ten.snapshot_hz == 10
|
||||
with pytest.raises(ConfigError, match=r"UNDERCOOKED_SNAPSHOT_HZ.*must be >= 1, <= 10"):
|
||||
load_settings({ENV_KEYS["tick_hz"]: "10", ENV_KEYS["snapshot_hz"]: "20"})
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------- validation errors
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("field", "raw", "message"),
|
||||
[
|
||||
("port", "http", "UNDERCOOKED_PORT='http' is not an integer"),
|
||||
("port", "8.5", "UNDERCOOKED_PORT='8.5' is not an integer"),
|
||||
("port", "0", "UNDERCOOKED_PORT=0 is invalid: must be >= 1, <= 65535"),
|
||||
("port", "65536", "UNDERCOOKED_PORT=65536 is invalid: must be >= 1, <= 65535"),
|
||||
("tick_hz", "4", "UNDERCOOKED_TICK_HZ=4 is invalid: must be >= 5, <= 60"),
|
||||
("tick_hz", "99", "UNDERCOOKED_TICK_HZ=99 is invalid: must be >= 5, <= 60"),
|
||||
("tick_hz", "twenty", "UNDERCOOKED_TICK_HZ='twenty' is not an integer"),
|
||||
("snapshot_hz", "0", "UNDERCOOKED_SNAPSHOT_HZ=0 is invalid: must be >= 1, <= 20"),
|
||||
("max_players", "0", "UNDERCOOKED_MAX_PLAYERS=0 is invalid: must be >= 1, <= 4"),
|
||||
("max_players", "5", "UNDERCOOKED_MAX_PLAYERS=5 is invalid: must be >= 1, <= 4"),
|
||||
("max_rooms", "0", "UNDERCOOKED_MAX_ROOMS=0 is invalid: must be >= 1"),
|
||||
("max_rooms", "-3", "UNDERCOOKED_MAX_ROOMS=-3 is invalid: must be >= 1"),
|
||||
(
|
||||
"lobby_idle_timeout_s",
|
||||
"-1",
|
||||
"UNDERCOOKED_LOBBY_IDLE_TIMEOUT_S=-1.0 is invalid: must be >= 0.0",
|
||||
),
|
||||
("lobby_idle_timeout_s", "soon", "UNDERCOOKED_LOBBY_IDLE_TIMEOUT_S='soon' is not a number"),
|
||||
(
|
||||
"guest_idle_timeout_s",
|
||||
"-0.5",
|
||||
"UNDERCOOKED_GUEST_IDLE_TIMEOUT_S=-0.5 is invalid: must be >= 0.0",
|
||||
),
|
||||
(
|
||||
"max_message_bytes",
|
||||
"255",
|
||||
"UNDERCOOKED_MAX_MESSAGE_BYTES=255 is invalid: must be >= 256",
|
||||
),
|
||||
("max_inputs_per_s", "0", "UNDERCOOKED_MAX_INPUTS_PER_S=0 is invalid: must be >= 1"),
|
||||
("sim_latency_ms", "-1", "UNDERCOOKED_SIM_LATENCY_MS=-1 is invalid: must be >= 0"),
|
||||
("debugpy_port", "0", "DEBUGPY_PORT=0 is invalid: must be >= 1, <= 65535"),
|
||||
(
|
||||
"debugpy",
|
||||
"maybe",
|
||||
"DEBUGPY='maybe' is not a boolean (use 1/0, true/false, yes/no, on/off)",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_invalid_values_raise_config_error(field: str, raw: str, message: str) -> None:
|
||||
with pytest.raises(ConfigError) as excinfo:
|
||||
load_settings({ENV_KEYS[field]: raw})
|
||||
assert str(excinfo.value) == message
|
||||
|
||||
|
||||
def test_out_of_range_tick_hz_fails_from_the_real_environment() -> None:
|
||||
"""The documented criterion: `UNDERCOOKED_TICK_HZ=99` fails loudly at startup."""
|
||||
env = {
|
||||
key: value
|
||||
for key, value in os.environ.items()
|
||||
if not key.startswith(("UNDERCOOKED_", "DEBUGPY"))
|
||||
}
|
||||
env["UNDERCOOKED_TICK_HZ"] = "99"
|
||||
result = subprocess.run( # fixed argv, repo's own interpreter
|
||||
[sys.executable, "-c", "from undercooked.config import load_settings; load_settings()"],
|
||||
cwd=REPO_ROOT,
|
||||
env=env,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
timeout=120,
|
||||
)
|
||||
assert result.returncode != 0
|
||||
assert "ConfigError" in result.stderr
|
||||
assert "UNDERCOOKED_TICK_HZ=99" in result.stderr
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------- cached accessor
|
||||
|
||||
|
||||
def test_get_settings_caches_until_reset(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||
monkeypatch.chdir(tmp_path)
|
||||
monkeypatch.setenv("UNDERCOOKED_PORT", "8123")
|
||||
first = get_settings()
|
||||
assert first.port == 8123
|
||||
monkeypatch.setenv("UNDERCOOKED_PORT", "9999")
|
||||
assert get_settings() is first # still the cached object
|
||||
reset_settings_cache()
|
||||
assert get_settings().port == 9999
|
||||
|
||||
|
||||
def test_get_settings_applies_defaults_with_an_empty_environment(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
for key in list(os.environ):
|
||||
if key.startswith(("UNDERCOOKED_", "DEBUGPY")):
|
||||
monkeypatch.delenv(key, raising=False)
|
||||
monkeypatch.setenv("PYTHON_DOTENV_DISABLED", "true")
|
||||
assert get_settings() == Settings()
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------- .env loading
|
||||
|
||||
|
||||
def test_dotenv_file_is_read_from_the_current_directory(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||
) -> None:
|
||||
monkeypatch.delenv("UNDERCOOKED_PORT", raising=False)
|
||||
monkeypatch.delenv("UNDERCOOKED_MAX_ROOMS", raising=False)
|
||||
(tmp_path / ".env").write_text(
|
||||
"UNDERCOOKED_PORT=8123\nUNDERCOOKED_MAX_ROOMS=7\n", encoding="utf-8"
|
||||
)
|
||||
monkeypatch.chdir(tmp_path)
|
||||
settings = load_settings()
|
||||
assert (settings.port, settings.max_rooms) == (8123, 7)
|
||||
|
||||
|
||||
def test_dotenv_is_found_in_a_parent_directory(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||
) -> None:
|
||||
monkeypatch.delenv("UNDERCOOKED_PORT", raising=False)
|
||||
(tmp_path / ".env").write_text("UNDERCOOKED_PORT=8245\n", encoding="utf-8")
|
||||
nested = tmp_path / "src" / "undercooked"
|
||||
nested.mkdir(parents=True)
|
||||
monkeypatch.chdir(nested)
|
||||
assert load_settings().port == 8245
|
||||
|
||||
|
||||
def test_dotenv_never_overrides_an_exported_variable(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||
) -> None:
|
||||
monkeypatch.setenv("UNDERCOOKED_PORT", "7000")
|
||||
(tmp_path / ".env").write_text("UNDERCOOKED_PORT=8123\n", encoding="utf-8")
|
||||
monkeypatch.chdir(tmp_path)
|
||||
assert load_settings().port == 7000
|
||||
|
||||
|
||||
def test_dotenv_is_read_once_until_the_cache_is_reset(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||
) -> None:
|
||||
loaded: list[str] = []
|
||||
(tmp_path / ".env").write_text("UNDERCOOKED_PORT=8123\n", encoding="utf-8")
|
||||
monkeypatch.chdir(tmp_path)
|
||||
def fake_load_dotenv(path: object, **_kwargs: object) -> bool:
|
||||
loaded.append(str(path))
|
||||
return True
|
||||
|
||||
monkeypatch.setattr(config, "load_dotenv", fake_load_dotenv)
|
||||
load_settings()
|
||||
load_settings()
|
||||
assert len(loaded) == 1 # one `.env` read per process, not one per call
|
||||
reset_settings_cache()
|
||||
load_settings()
|
||||
assert len(loaded) == 2
|
||||
|
||||
|
||||
def test_missing_dotenv_file_is_not_an_error(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||
) -> None:
|
||||
monkeypatch.delenv("UNDERCOOKED_PORT", raising=False)
|
||||
monkeypatch.chdir(tmp_path)
|
||||
assert load_settings().port == 8000
|
||||
|
||||
|
||||
def test_explicit_env_mapping_skips_dotenv(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||
monkeypatch.chdir(tmp_path)
|
||||
def never_find(**_kwargs: object) -> str:
|
||||
pytest.fail("dotenv must not be read when an explicit env mapping is given")
|
||||
|
||||
monkeypatch.setattr(config, "find_dotenv", never_find)
|
||||
assert load_settings({"UNDERCOOKED_PORT": "8123"}).port == 8123
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------- .env.example
|
||||
|
||||
|
||||
def parse_env_example(path: Path) -> dict[str, str]:
|
||||
"""Read `KEY=value` lines from an env template, ignoring comments."""
|
||||
values: dict[str, str] = {}
|
||||
for line in path.read_text(encoding="utf-8").splitlines():
|
||||
stripped = line.strip()
|
||||
if not stripped or stripped.startswith("#") or "=" not in stripped:
|
||||
continue
|
||||
key, _, value = stripped.partition("=")
|
||||
values[key.strip()] = value.strip()
|
||||
return values
|
||||
|
||||
|
||||
def test_env_example_documents_every_variable_with_its_default() -> None:
|
||||
example = parse_env_example(REPO_ROOT / ".env.example")
|
||||
assert set(example) == set(ENV_KEYS.values())
|
||||
assert load_settings(example) == Settings()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("forbidden", ["token", "password", "secret", "api_key", "reese", ".com"])
|
||||
def test_env_example_values_carry_no_secrets_or_hostnames(forbidden: str) -> None:
|
||||
"""The template is committed, so its values must stay placeholders (A16/A17)."""
|
||||
for key, value in parse_env_example(REPO_ROOT / ".env.example").items():
|
||||
assert forbidden not in value.lower(), f"{key} looks like it holds a secret or a real host"
|
||||
assert len(value) <= 40, f"{key} is too long to be a default"
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------- README table
|
||||
|
||||
|
||||
def parse_readme_config_table(path: Path) -> dict[str, str]:
|
||||
"""Read the VAR / default / meaning rows of the README configuration table.
|
||||
|
||||
A default written as *(empty)* means "no value", i.e. an empty string.
|
||||
"""
|
||||
lines = path.read_text(encoding="utf-8").splitlines()
|
||||
try:
|
||||
start = lines.index("## Configuration")
|
||||
except ValueError: # pragma: no cover - fails loudly if the section is renamed
|
||||
pytest.fail("README.md has no '## Configuration' section")
|
||||
table: dict[str, str] = {}
|
||||
for line in lines[start + 1 :]:
|
||||
if line.startswith("## "):
|
||||
break
|
||||
stripped = line.strip()
|
||||
if not stripped.startswith("|"):
|
||||
continue
|
||||
cells = [cell.strip() for cell in stripped.strip("|").split("|")]
|
||||
key = cells[0].strip("`")
|
||||
if not key.isupper():
|
||||
continue # header and separator rows
|
||||
default = cells[1].strip("`")
|
||||
table[key] = "" if default.startswith("*") else default
|
||||
return table
|
||||
|
||||
|
||||
def test_readme_documents_every_variable_with_its_default() -> None:
|
||||
table = parse_readme_config_table(REPO_ROOT / "README.md")
|
||||
assert set(table) == set(ENV_KEYS.values())
|
||||
assert load_settings(table) == Settings()
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------- CLI overrides
|
||||
|
||||
|
||||
def test_apply_cli_overrides_publishes_env_and_rebuilds(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.delenv("UNDERCOOKED_LEVELS_DIR", raising=False)
|
||||
settings = apply_cli_overrides(levels_dir=Path("kitchens"), port=9123)
|
||||
assert settings.levels_dir == Path("kitchens")
|
||||
assert settings.port == 9123
|
||||
assert os.environ["UNDERCOOKED_LEVELS_DIR"] == "kitchens"
|
||||
assert get_settings() == settings # a later get_settings() sees the same thing
|
||||
|
||||
|
||||
def test_apply_cli_overrides_beats_an_existing_environment_value(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setenv("UNDERCOOKED_PORT", "8123")
|
||||
assert apply_cli_overrides(port=9200).port == 9200
|
||||
|
||||
|
||||
def test_apply_cli_overrides_validates_the_values_it_writes() -> None:
|
||||
with pytest.raises(ConfigError, match=r"UNDERCOOKED_PORT=0 is invalid"):
|
||||
apply_cli_overrides(port=0)
|
||||
|
||||
|
||||
def test_apply_cli_overrides_rejects_an_unknown_field() -> None:
|
||||
with pytest.raises(ConfigError, match="'nonsense' is not a configuration field"):
|
||||
apply_cli_overrides(**{"nonsense": 1})
|
||||
|
||||
|
||||
def test_apply_cli_overrides_renders_booleans_as_env_flags() -> None:
|
||||
assert apply_cli_overrides(debugpy=True).debugpy is True
|
||||
assert os.environ["DEBUGPY"] == "1"
|
||||
assert apply_cli_overrides(debugpy=False).debugpy is False
|
||||
assert os.environ["DEBUGPY"] == "0"
|
||||
|
||||
|
||||
def test_apply_cli_overrides_with_nothing_still_validates_the_environment(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setenv("UNDERCOOKED_TICK_HZ", "99")
|
||||
with pytest.raises(ConfigError, match="UNDERCOOKED_TICK_HZ=99"):
|
||||
apply_cli_overrides()
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------- conftest factory
|
||||
|
||||
|
||||
def test_make_settings_fixture_builds_settings(make_settings: Callable[..., Settings]) -> None:
|
||||
settings = make_settings(tick_hz=30, snapshot_hz=15, debugpy=True, allowed_origins=["http://a", "http://b"])
|
||||
assert (settings.tick_hz, settings.snapshot_hz) == (30, 15)
|
||||
assert settings.debugpy is True
|
||||
assert settings.allowed_origins == ("http://a", "http://b")
|
||||
assert settings.levels_dir == Path("levels")
|
||||
@@ -0,0 +1,114 @@
|
||||
"""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"
|
||||
@@ -0,0 +1,409 @@
|
||||
"""Docs guard: what `README.md` tells a human to type must be real (phase 01 task 04).
|
||||
|
||||
Documentation rots in two ways this file makes impossible:
|
||||
|
||||
1. **Pseudo-commands.** A fenced block that reads plausibly but cannot run — an invented
|
||||
script, a tool that is not part of this project, a placeholder like `make deploy`.
|
||||
Every command line inside a `bash`/`sh`/`console`/`shell` fence of `README.md` or
|
||||
`docs/*.md` must start with a known-safe prefix, and whatever it launches through
|
||||
`uv run` must be a console script or a dependency declared in `pyproject.toml`. That is
|
||||
what keeps the Quickstart true after a rename — and what stops a promise (e.g. the
|
||||
phase-02 `undercooked-levels` CLI) from being written as a runnable command before it
|
||||
exists.
|
||||
2. **Config drift.** A variable documented but not implemented, or implemented but never
|
||||
documented. AGENTS.md §5 requires the `Settings` field, the `ENV_KEYS` entry, the
|
||||
`.env.example` line and the README row to land together; here the documentation side is
|
||||
compared against `config.py` and `.env.example`, so they cannot disagree.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import tomllib
|
||||
from collections.abc import Iterable
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from undercooked.config import ENV_KEYS
|
||||
|
||||
pytestmark = pytest.mark.unit
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
README = REPO_ROOT / "README.md"
|
||||
ENV_EXAMPLE = REPO_ROOT / ".env.example"
|
||||
PYPROJECT = REPO_ROOT / "pyproject.toml"
|
||||
|
||||
#: Markdown documents whose command blocks a human may copy.
|
||||
DOCUMENTED_MARKDOWN: tuple[Path, ...] = (README, *sorted((REPO_ROOT / "docs").glob("*.md")))
|
||||
|
||||
#: Shell fences that promise "type this".
|
||||
COMMAND_FENCE_LANGUAGES = frozenset({"bash", "sh", "console", "shell"})
|
||||
|
||||
#: The only things a documented command line may start with. Anything else is prose
|
||||
#: wearing a code fence, and it will be deleted rather than run.
|
||||
SAFE_PREFIXES = ("uv ", "podman ", "bash .agents/validate.sh", "curl ", "cd ", "#")
|
||||
|
||||
#: `FOO=bar cmd …` is still a command; only documented variables may lead a line.
|
||||
ENV_ASSIGNMENT = re.compile(r"""^(?:[A-Z][A-Z0-9_]*=(?:"[^"]*"|'[^']*'|\S)+\s+)+""")
|
||||
|
||||
#: Every `UNDERCOOKED_*` / `DEBUGPY*` name written anywhere in prose, a table or a fence.
|
||||
#: A name has to start with a real character, so the `UNDERCOOKED_*` wildcard in prose is
|
||||
#: not read as a variable nobody implemented.
|
||||
DOCUMENTED_VARIABLE = re.compile(r"\bUNDERCOOKED_[A-Z0-9_]*[A-Z0-9]\b|\bDEBUGPY(?:_[A-Z0-9]+)?\b")
|
||||
|
||||
#: `uv run` options that consume the token after them, so the program name is further along.
|
||||
UV_VALUE_OPTIONS = frozenset({"--extra", "--python", "--with", "--directory", "--project", "-m"})
|
||||
|
||||
#: `KEY=value` lines of an env template.
|
||||
_TEMPLATE_VARIABLE = re.compile(r"^([A-Z][A-Z0-9_]*)\s*=")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Command:
|
||||
"""One logical command line from a fenced block (continuations joined)."""
|
||||
|
||||
document: str
|
||||
line: int
|
||||
text: str
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.document}:{self.line}: {self.text}"
|
||||
|
||||
|
||||
def _strip_assignment(command: str) -> tuple[tuple[str, ...], str]:
|
||||
"""Split `A=1 B=2 uv run …` into its variable names and the command underneath."""
|
||||
names: list[str] = []
|
||||
while match := ENV_ASSIGNMENT.match(command):
|
||||
names.extend(part.split("=", 1)[0] for part in match.group(0).split())
|
||||
command = command[match.end(0) :].lstrip()
|
||||
return tuple(names), command
|
||||
|
||||
|
||||
def fenced_commands(markdown: str, *, document: str = "<memory>") -> list[Command]:
|
||||
"""Every command inside a `bash`-family fence, with the line it started on.
|
||||
|
||||
A trailing-backslash continuation becomes one `Command`, because only the first line
|
||||
of a wrapped command carries the program name.
|
||||
"""
|
||||
commands: list[Command] = []
|
||||
fence: str | None = None
|
||||
pending: list[str] = []
|
||||
pending_line = 0
|
||||
for number, raw in enumerate(markdown.splitlines(), start=1):
|
||||
marker = re.match(r"^\s*```(\w*)\s*$", raw)
|
||||
if marker:
|
||||
fence = None if fence is not None else marker.group(1).lower()
|
||||
if pending:
|
||||
commands.append(Command(document, pending_line, " ".join(pending)))
|
||||
pending = []
|
||||
continue
|
||||
if fence is None or fence not in COMMAND_FENCE_LANGUAGES:
|
||||
continue
|
||||
stripped = raw.strip()
|
||||
if not stripped:
|
||||
if pending:
|
||||
commands.append(Command(document, pending_line, " ".join(pending)))
|
||||
pending = []
|
||||
continue
|
||||
if not pending:
|
||||
pending_line = number
|
||||
if stripped.endswith("\\"):
|
||||
pending.append(stripped[:-1].strip())
|
||||
continue
|
||||
pending.append(stripped)
|
||||
commands.append(Command(document, pending_line, " ".join(pending)))
|
||||
pending = []
|
||||
if pending: # pragma: no cover - a document ending mid-continuation
|
||||
commands.append(Command(document, pending_line, " ".join(pending)))
|
||||
return commands
|
||||
|
||||
|
||||
def uv_run_program(command: str) -> str | None:
|
||||
"""The program a `uv run …` line launches, with uv's own options skipped; else `None`.
|
||||
|
||||
That program must be declared in `pyproject.toml`, which is what makes a doc line
|
||||
inventing a script fail the build.
|
||||
"""
|
||||
if not command.startswith("uv run"):
|
||||
return None
|
||||
tokens = command.split()[2:]
|
||||
index = 0
|
||||
while index < len(tokens):
|
||||
token = tokens[index]
|
||||
if token in UV_VALUE_OPTIONS:
|
||||
index += 2
|
||||
continue
|
||||
if token.startswith("-"):
|
||||
index += 1
|
||||
continue
|
||||
return token.lower()
|
||||
return None
|
||||
|
||||
|
||||
def declared_uv_programs() -> frozenset[str]:
|
||||
"""Program names `uv run` may launch: this project's scripts plus its dependencies."""
|
||||
|
||||
def names(requirements: list[str]) -> set[str]:
|
||||
return {re.split(r"[<>=!;\[ ]", name)[0].strip().lower() for name in requirements}
|
||||
|
||||
data = tomllib.loads(PYPROJECT.read_text(encoding="utf-8"))
|
||||
project = data["project"]
|
||||
packages = names(list(project.get("dependencies", [])))
|
||||
for extra in project.get("optional-dependencies", {}).values():
|
||||
packages |= names(list(extra))
|
||||
return frozenset(set(project.get("scripts", {})) | packages | {"python"})
|
||||
|
||||
|
||||
def template_variables() -> set[str]:
|
||||
"""Names declared in the committed `.env.example` template (comments ignored)."""
|
||||
return {
|
||||
match.group(1)
|
||||
for line in ENV_EXAMPLE.read_text(encoding="utf-8").splitlines()
|
||||
if (match := _TEMPLATE_VARIABLE.match(line.strip()))
|
||||
}
|
||||
|
||||
|
||||
def variables_in(path: Path) -> set[str]:
|
||||
"""Every configuration variable named anywhere in one markdown file."""
|
||||
return set(DOCUMENTED_VARIABLE.findall(path.read_text(encoding="utf-8")))
|
||||
|
||||
|
||||
def _fail(header: str, offenders: list[str]) -> None:
|
||||
pytest.fail(f"{header}\n" + "\n".join(f" {offender}" for offender in offenders))
|
||||
|
||||
|
||||
def unsafe_commands(commands: Iterable[Command]) -> list[str]:
|
||||
"""Commands whose first token is none of `SAFE_PREFIXES`, once env assignments are peeled."""
|
||||
offenders: list[str] = []
|
||||
for command in commands:
|
||||
_, bare = _strip_assignment(command.text)
|
||||
if not bare.startswith(SAFE_PREFIXES):
|
||||
offenders.append(str(command))
|
||||
return offenders
|
||||
|
||||
|
||||
def undeclared_programs(commands: Iterable[Command], allowed: frozenset[str]) -> list[str]:
|
||||
"""`uv run <thing>` lines whose `<thing>` pyproject.toml does not declare."""
|
||||
offenders: list[str] = []
|
||||
for command in commands:
|
||||
_, bare = _strip_assignment(command.text)
|
||||
program = uv_run_program(bare)
|
||||
if program is not None and program not in allowed:
|
||||
offenders.append(f"{command} -> {program!r} is not in pyproject.toml")
|
||||
return offenders
|
||||
|
||||
|
||||
def documented_but_unimplemented(documented: set[str], implemented: set[str]) -> list[str]:
|
||||
"""Variables the docs name and `config.py` does not read."""
|
||||
return sorted(documented - implemented)
|
||||
|
||||
|
||||
def implemented_but_undocumented(implemented: set[str], documented: set[str]) -> list[str]:
|
||||
"""Variables `config.py` reads and the docs never mention."""
|
||||
return sorted(set(implemented) - documented)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- command blocks
|
||||
|
||||
|
||||
def test_readme_has_command_blocks_worth_checking() -> None:
|
||||
"""Non-vacuous: if the Quickstart stops being a bash fence, this has to notice."""
|
||||
commands = fenced_commands(README.read_text(encoding="utf-8"), document="README.md")
|
||||
assert len(commands) >= 8, f"README.md has lost its command blocks: {commands}"
|
||||
assert any(command.text.startswith("uv run undercooked") for command in commands)
|
||||
assert any(command.text.startswith("uv run pytest") for command in commands)
|
||||
|
||||
|
||||
def test_documented_command_lines_start_with_a_known_prefix() -> None:
|
||||
"""No pseudo-commands in the docs a player or an operator copy-pastes from."""
|
||||
offenders: list[str] = []
|
||||
for document in DOCUMENTED_MARKDOWN:
|
||||
commands = fenced_commands(document.read_text(encoding="utf-8"), document=document.name)
|
||||
offenders.extend(unsafe_commands(commands))
|
||||
if offenders:
|
||||
_fail(f"documented commands must start with one of {SAFE_PREFIXES}:", offenders)
|
||||
|
||||
|
||||
def test_documented_commands_only_name_declared_programs() -> None:
|
||||
"""`uv run <thing>` may only launch a script or dependency of this project."""
|
||||
allowed = declared_uv_programs()
|
||||
offenders: list[str] = []
|
||||
for document in DOCUMENTED_MARKDOWN:
|
||||
commands = fenced_commands(document.read_text(encoding="utf-8"), document=document.name)
|
||||
offenders.extend(undeclared_programs(commands, allowed))
|
||||
if offenders:
|
||||
_fail(f"`uv run` may only run {sorted(allowed)}:", offenders)
|
||||
|
||||
|
||||
def test_command_fence_is_a_fence_and_not_a_table() -> None:
|
||||
"""Pins the extractor: fenced bash is a command, indented prose and other fences are not."""
|
||||
sample = "\n".join(
|
||||
[
|
||||
"text `not-a-command --yolo`", # 1: inline code is not a command
|
||||
"",
|
||||
"```bash", # 3
|
||||
"uv run undercooked \\", # 4 \\ the continuation joins the next line
|
||||
" --port 8137", # 5
|
||||
"", # 6: a blank line ends a command, it never joins across it
|
||||
"uv run pytest -q", # 7
|
||||
"# a comment", # 8
|
||||
"uv run ruff check \\", # 9 \\ a continuation cut off by a blank line still counts
|
||||
"",
|
||||
"uv run pyright \\", # 11 \\ and one cut off by the closing fence counts too
|
||||
"```",
|
||||
"",
|
||||
"```text", # 14: not a command fence
|
||||
"made-up-command --yolo",
|
||||
"```",
|
||||
]
|
||||
)
|
||||
assert fenced_commands(sample) == [
|
||||
Command("<memory>", 4, "uv run undercooked --port 8137"),
|
||||
Command("<memory>", 7, "uv run pytest -q"),
|
||||
Command("<memory>", 8, "# a comment"),
|
||||
Command("<memory>", 9, "uv run ruff check"),
|
||||
Command("<memory>", 11, "uv run pyright"),
|
||||
]
|
||||
assert str(Command("README.md", 12, "uv run undercooked")) == "README.md:12: uv run undercooked"
|
||||
|
||||
|
||||
def test_leading_environment_assignments_are_peeled_off() -> None:
|
||||
names, bare = _strip_assignment("DEBUGPY=1 UNDERCOOKED_PORT=8137 uv run undercooked")
|
||||
assert names == ("DEBUGPY", "UNDERCOOKED_PORT")
|
||||
assert bare == "uv run undercooked"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("command", "program"),
|
||||
[
|
||||
("uv run pytest -q", "pytest"),
|
||||
("uv run --extra dev ruff check .", "ruff"),
|
||||
("uv run undercooked --reload", "undercooked"),
|
||||
("uv run python -c 'print(1)'", "python"),
|
||||
],
|
||||
)
|
||||
def test_the_uv_run_extractor_finds_the_program(command: str, program: str) -> None:
|
||||
assert uv_run_program(command) == program
|
||||
|
||||
|
||||
@pytest.mark.parametrize("command", ["podman build .", "uv sync --extra dev", "cd .", "uv run", ""])
|
||||
def test_the_uv_run_extractor_returns_none_for_everything_else(command: str) -> None:
|
||||
assert uv_run_program(command) is None
|
||||
|
||||
|
||||
def test_the_declared_programs_cover_the_real_toolchain() -> None:
|
||||
"""Derived from pyproject.toml, so a dependency removal breaks the docs loudly."""
|
||||
allowed = declared_uv_programs()
|
||||
assert {"undercooked", "pytest", "ruff", "pyright", "playwright", "python"} <= allowed
|
||||
assert "undercooked-levels" in allowed # declared now, importable from phase 02
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- variable names
|
||||
|
||||
|
||||
def test_every_variable_named_in_the_docs_is_implemented() -> None:
|
||||
"""README and docs may only name a variable `config.py` or `.env.example` defines."""
|
||||
implemented = set(ENV_KEYS.values()) | template_variables()
|
||||
offenders = [
|
||||
f"{document.name}: {name}"
|
||||
for document in DOCUMENTED_MARKDOWN
|
||||
for name in documented_but_unimplemented(variables_in(document), implemented)
|
||||
]
|
||||
if offenders:
|
||||
_fail("documented but not implemented:", offenders)
|
||||
|
||||
|
||||
def test_every_implemented_variable_is_documented() -> None:
|
||||
"""The reverse direction: a knob nobody documented is a knob nobody can find."""
|
||||
documented = variables_in(README) | variables_in(ENV_EXAMPLE)
|
||||
missing = implemented_but_undocumented(set(ENV_KEYS.values()), documented)
|
||||
if missing:
|
||||
_fail("config.py defines variables neither README.md nor .env.example mentions:", missing)
|
||||
|
||||
|
||||
def test_readme_and_env_template_name_the_same_variables() -> None:
|
||||
"""The two operator-facing lists cannot diverge from each other."""
|
||||
documented, template = variables_in(README), variables_in(ENV_EXAMPLE)
|
||||
only_readme = sorted(documented - template)
|
||||
only_template = sorted(template - documented)
|
||||
assert not only_readme, f"named in README.md but absent from .env.example: {only_readme}"
|
||||
assert not only_template, f"named in .env.example but absent from README.md: {only_template}"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- the gate is documented
|
||||
|
||||
|
||||
def test_readme_documents_the_gate_and_the_cdn_rule() -> None:
|
||||
"""The commands AGENTS.md calls binding are findable in the human-facing docs."""
|
||||
readme = README.read_text(encoding="utf-8")
|
||||
assert "bash .agents/validate.sh" in readme, "the pipeline gate is not documented in README"
|
||||
assert re.search(r"no external cdns", readme, re.IGNORECASE), "A12 is not stated in README"
|
||||
|
||||
|
||||
def test_a_pseudo_command_is_reported() -> None:
|
||||
"""The prefix rule actually fires: planted prose-commands are named, real ones are not."""
|
||||
sample = "\n".join(
|
||||
[
|
||||
"```bash", # 1
|
||||
"uv run undercooked --port 8137", # 2
|
||||
"DEBUGPY=1 uv run undercooked", # 3: an env lead is still a command
|
||||
"make deploy-to-staging", # 4
|
||||
"kubectl port-forward svc/undercooked 8000", # 5
|
||||
"# a comment is a valid line", # 6
|
||||
"```",
|
||||
]
|
||||
)
|
||||
offenders = unsafe_commands(fenced_commands(sample, document="<memory>"))
|
||||
assert offenders == [
|
||||
"<memory>:4: make deploy-to-staging",
|
||||
"<memory>:5: kubectl port-forward svc/undercooked 8000",
|
||||
]
|
||||
|
||||
|
||||
def test_an_invented_program_is_reported() -> None:
|
||||
"""A doc line cannot run a script pyproject.toml does not declare."""
|
||||
sample = "\n".join(
|
||||
[
|
||||
"```bash",
|
||||
"uv run --frozen undercooked-levels validate",
|
||||
"uv run made-up-cli validate",
|
||||
"```",
|
||||
]
|
||||
)
|
||||
commands = fenced_commands(sample, document="<memory>")
|
||||
offenders = undeclared_programs(commands, frozenset({"undercooked-levels"}))
|
||||
assert len(offenders) == 1, offenders
|
||||
assert "made-up-cli" in offenders[0]
|
||||
|
||||
|
||||
def test_the_variable_diffs_work_in_both_directions() -> None:
|
||||
"""The two drift directions are tested, not just asserted to be empty on today's docs."""
|
||||
implemented = {"UNDERCOOKED_PORT", "DEBUGPY"}
|
||||
documented = {"UNDERCOOKED_PORT", "DEBUGPY", "UNDERCOOKED_MADE_UP", "DEBUGPY_PORT"}
|
||||
assert documented_but_unimplemented(documented, implemented) == [
|
||||
"DEBUGPY_PORT",
|
||||
"UNDERCOOKED_MADE_UP",
|
||||
]
|
||||
assert implemented_but_undocumented(implemented, documented) == []
|
||||
assert implemented_but_undocumented({"UNDERCOOKED_NEW_KNOB"}, documented) == [
|
||||
"UNDERCOOKED_NEW_KNOB"
|
||||
]
|
||||
|
||||
|
||||
def test_the_failure_helper_lists_every_offender() -> None:
|
||||
"""`_fail` is the message an executor reads; it has to enumerate, not truncate."""
|
||||
with pytest.raises(pytest.fail.Exception, match="two"):
|
||||
_fail("broken:", ["one", "two"])
|
||||
|
||||
|
||||
def test_agents_md_only_names_test_paths_that_exist() -> None:
|
||||
"""Every concrete `tests/**.py|md` path AGENTS.md cites is a real file."""
|
||||
agents = (REPO_ROOT / "AGENTS.md").read_text(encoding="utf-8")
|
||||
cited = re.findall(r"tests/[A-Za-z0-9_./-]+\.(?:py|md)", agents)
|
||||
assert "tests/unit/test_no_external_assets.py" in cited
|
||||
assert cited, "AGENTS.md cites no test files at all"
|
||||
missing = sorted({path for path in cited if not (REPO_ROOT / path).is_file()})
|
||||
assert missing == [], f"AGENTS.md points at files that do not exist: {missing}"
|
||||
|
||||
@@ -0,0 +1,357 @@
|
||||
"""PLAN §13 doc-index guard: the promised documentation set and the `docs/` tree cannot drift.
|
||||
|
||||
`.agents/PLAN.md` §13 closes with the canonical documentation index and the sentence
|
||||
"`tests/unit/test_docs_index.py` (phase 01) keeps this table and the `docs/` tree identical,
|
||||
so neither can drift silently." This is that file. It closes both directions of drift:
|
||||
|
||||
* **A promised doc is missing.** Every index row whose creating phase has already completed
|
||||
(according to the `.agents/phases/complete/` tree) must exist on disk, so no phase can
|
||||
reference a document that was never written. A range in the "Created by" column —
|
||||
`phase 02/03` — promises the file by the *end* of that range, so its obligation starts at
|
||||
the later phase; a `→` marks the stub-then-fill pattern, where the earlier phase owes the
|
||||
stub.
|
||||
* **A doc exists that nothing promises.** Every `docs/*.md` must be listed in the index or be
|
||||
a document a phase file of the roadmap itself creates — `docs/ROOMS.md` comes from phase 05
|
||||
and is not in §13, so the roadmap may extend the doc set, but an untracked file may not
|
||||
appear there silently.
|
||||
|
||||
Only markdown is compared: the index promises *documents*, and a diagram or fixture a phase
|
||||
puts under `docs/` is not a documentation promise.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = pytest.mark.unit
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
PLAN = REPO_ROOT / ".agents/PLAN.md"
|
||||
DOCS_DIR = REPO_ROOT / "docs"
|
||||
PHASES_DIR = REPO_ROOT / ".agents/phases"
|
||||
COMPLETE_DIR = PHASES_DIR / "complete"
|
||||
|
||||
INDEX_HEADING = "### Documentation index (canonical)"
|
||||
PHASE_DIRECTORY = re.compile(r"^(\d{2})_")
|
||||
PHASE_REFERENCE = re.compile(r"phase\s+(\d{1,2})", re.IGNORECASE)
|
||||
NUMBER = re.compile(r"\d{1,2}")
|
||||
DOC_REFERENCE = re.compile(r"docs/[A-Za-z0-9_-]+\.md")
|
||||
|
||||
#: The index exactly as PLAN §13 writes it. Changing this set means editing PLAN.md first.
|
||||
CANONICAL_DOCS = frozenset(
|
||||
{
|
||||
"README.md",
|
||||
"AGENTS.md",
|
||||
"BUDGETS.md",
|
||||
"docs/DESIGN.md",
|
||||
"docs/SIMULATION.md",
|
||||
"docs/LEVELS.md",
|
||||
"docs/PROTOCOL.md",
|
||||
"docs/CLIENT.md",
|
||||
"docs/DEPLOYMENT.md",
|
||||
"docs/TESTING.md",
|
||||
"docs/ACCESSIBILITY.md",
|
||||
"docs/SECURITY.md",
|
||||
"tests/e2e/README.md",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DocRow:
|
||||
"""One row of the PLAN §13 index: path, contents, and the phase(s) that create it."""
|
||||
|
||||
path: str
|
||||
contents: str
|
||||
created_by: str
|
||||
|
||||
@property
|
||||
def promised_from(self) -> int:
|
||||
"""First phase after which this file must exist.
|
||||
|
||||
The first comma/arrow-separated group is the creation promise. Inside it, a slash
|
||||
range (`phase 02/03`) is owed by the end of the range, so the later number wins;
|
||||
anything else is owed by the earliest number mentioned.
|
||||
"""
|
||||
first_group = re.split(r"[→,;]", self.created_by, maxsplit=1)[0]
|
||||
numbers = [int(number) for number in NUMBER.findall(first_group)]
|
||||
if not numbers:
|
||||
numbers = [int(number) for number in PHASE_REFERENCE.findall(self.created_by)]
|
||||
if not numbers:
|
||||
pytest.fail(f"PLAN index row {self.path!r} names no phase: {self.created_by!r}")
|
||||
return numbers[-1] if "/" in first_group and len(numbers) > 1 else numbers[0]
|
||||
|
||||
|
||||
def parse_documentation_index(plan_text: str) -> list[DocRow]:
|
||||
"""Read the canonical documentation-index table out of PLAN.md text."""
|
||||
lines = plan_text.splitlines()
|
||||
try:
|
||||
start = lines.index(INDEX_HEADING)
|
||||
except ValueError: # pragma: no cover - PLAN is locked; fail loudly if it is renamed
|
||||
pytest.fail(f".agents/PLAN.md has no {INDEX_HEADING!r} heading")
|
||||
rows: list[DocRow] = []
|
||||
for raw in lines[start + 1 :]:
|
||||
stripped = raw.strip()
|
||||
if not stripped.startswith("|"):
|
||||
if rows:
|
||||
break
|
||||
continue
|
||||
cells = [cell.strip() for cell in stripped.strip("|").split("|")]
|
||||
if len(cells) < 3 or cells[0].strip("`") in {"Path", ""} or set(cells[0]) <= {"-", ":"}:
|
||||
continue
|
||||
rows.append(DocRow(path=cells[0].strip("`"), contents=cells[1], created_by=cells[2]))
|
||||
return rows
|
||||
|
||||
|
||||
def latest_completed_phase(complete_dir: Path = COMPLETE_DIR) -> int:
|
||||
"""Highest phase number with anything in `complete/` (0 before the first phase finishes)."""
|
||||
return max(
|
||||
(
|
||||
int(match.group(1))
|
||||
for entry in complete_dir.glob("*")
|
||||
if (match := PHASE_DIRECTORY.match(entry.name))
|
||||
),
|
||||
default=0,
|
||||
)
|
||||
|
||||
|
||||
def roadmap_promised_docs(phases_dir: Path = PHASES_DIR) -> set[str]:
|
||||
"""Every `docs/*.md` a phase overview or task file itself names."""
|
||||
return {
|
||||
match.group(0)
|
||||
for phase_file in sorted(phases_dir.rglob("*.md"))
|
||||
for match in DOC_REFERENCE.finditer(phase_file.read_text(encoding="utf-8"))
|
||||
}
|
||||
|
||||
|
||||
def documentation_index(plan: Path = PLAN) -> list[DocRow]:
|
||||
return parse_documentation_index(plan.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def written_docs(docs_dir: Path = DOCS_DIR, *, root: Path = REPO_ROOT) -> set[str]:
|
||||
"""The markdown currently in `docs/`, as repo-relative paths."""
|
||||
return {path.relative_to(root).as_posix() for path in sorted(docs_dir.rglob("*.md"))}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- the index itself
|
||||
|
||||
|
||||
def test_the_documentation_index_is_parseable() -> None:
|
||||
rows = documentation_index()
|
||||
assert len(rows) >= len(CANONICAL_DOCS), rows
|
||||
paths = [row.path for row in rows]
|
||||
assert len(paths) == len(set(paths)), f"duplicate index rows: {paths}"
|
||||
assert all(row.contents and row.created_by for row in rows), paths
|
||||
|
||||
|
||||
def test_the_index_still_lists_the_canonical_doc_set() -> None:
|
||||
"""Adding or dropping a row is a PLAN §13 change and must fail a test first."""
|
||||
assert {row.path for row in documentation_index()} == CANONICAL_DOCS
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("created_by", "expected"),
|
||||
[
|
||||
("phase 01", 1),
|
||||
("phase 01, refined throughout", 1),
|
||||
("phase 01 (stub) \u2192 phase 09", 1),
|
||||
("phase 02, phase 11", 2),
|
||||
("phase 02/03", 3),
|
||||
("phase 14", 14),
|
||||
("refined throughout, phase 05", 5),
|
||||
],
|
||||
)
|
||||
def test_the_promised_from_rule_reads_the_phase_column(created_by: str, expected: int) -> None:
|
||||
row = DocRow(path="docs/X.md", contents="whatever", created_by=created_by)
|
||||
assert row.promised_from == expected
|
||||
|
||||
|
||||
def test_an_index_row_that_names_no_phase_fails_loudly() -> None:
|
||||
row = DocRow(path="docs/X.md", contents="whatever", created_by="someday, maybe")
|
||||
with pytest.raises(pytest.fail.Exception, match="names no phase"):
|
||||
_ = row.promised_from
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- promised vs present
|
||||
|
||||
|
||||
def test_every_doc_promised_by_a_completed_phase_exists() -> None:
|
||||
"""No phase may reference a document whose creating phase is already in `complete/`."""
|
||||
completed = latest_completed_phase()
|
||||
assert completed >= 1, "no completed phase found — the guard would be vacuous"
|
||||
missing = [
|
||||
f"{row.path} (owed by phase {row.promised_from:02d}, {completed:02d} is complete)"
|
||||
for row in documentation_index()
|
||||
if row.promised_from <= completed and not (REPO_ROOT / row.path).is_file()
|
||||
]
|
||||
report = "\n ".join(missing)
|
||||
assert missing == [], f"documentation the project owes but does not have:\n {report}"
|
||||
|
||||
|
||||
def test_no_doc_file_exists_that_nothing_promises() -> None:
|
||||
"""The `docs/` tree is exactly the index plus what the phase roadmap creates."""
|
||||
promised = {row.path for row in documentation_index()} | roadmap_promised_docs()
|
||||
orphans = sorted(written_docs() - promised)
|
||||
report = "\n ".join(orphans)
|
||||
assert orphans == [], (
|
||||
f"unpromised files under docs/ — add each to PLAN §13's index or to the phase "
|
||||
f"that creates it:\n {report}"
|
||||
)
|
||||
|
||||
|
||||
def test_the_docs_on_disk_are_the_three_phase_01_documents() -> None:
|
||||
"""Pins today's tree so that adding a document is a deliberate, named act."""
|
||||
assert written_docs() == {"docs/DESIGN.md", "docs/DEPLOYMENT.md", "docs/TESTING.md"}
|
||||
|
||||
|
||||
def stub_without_owner(relative: str, text: str) -> str | None:
|
||||
"""One sentence of complaint about a document, or `None` when it is honest.
|
||||
|
||||
Split out of the test below so a planted stub can prove each check fires; a guard that
|
||||
only ever sees healthy input is not a guard.
|
||||
"""
|
||||
if not text.startswith("# "):
|
||||
return f"{relative} does not open with a title"
|
||||
if "## Purpose" not in text:
|
||||
return f"{relative} has no Purpose section"
|
||||
purpose = text.split("## Purpose", 1)[1].split("\n##", 1)[0].strip()
|
||||
if len(purpose.split()) < 25:
|
||||
return f"{relative}'s Purpose paragraph is too thin to guide"
|
||||
names_stub = re.search(r"\bstub\b", text, re.IGNORECASE) is not None
|
||||
has_owner = re.search(r"[Ff]illed in by phase \d\d", text) is not None
|
||||
if names_stub and not has_owner:
|
||||
return f"{relative} is a stub with no 'Filled in by phase NN' marker"
|
||||
return None
|
||||
|
||||
|
||||
def dishonest_references(
|
||||
references: dict[str, set[str]], *, exists: Callable[[str], bool], owed_later: set[str]
|
||||
) -> list[str]:
|
||||
"""`document: path` for each cited doc that neither exists nor is owed by a later phase."""
|
||||
offenders: list[str] = []
|
||||
for name, cited in sorted(references.items()):
|
||||
offenders.extend(
|
||||
f"{name}: {path}"
|
||||
for path in sorted(cited)
|
||||
if not exists(path) and path not in owed_later
|
||||
)
|
||||
return offenders
|
||||
|
||||
|
||||
def test_the_stub_docs_say_who_fills_them_in() -> None:
|
||||
"""A stub with no owner stays a stub forever, so each one names its phase."""
|
||||
complaints = [
|
||||
complaint
|
||||
for relative in sorted(written_docs())
|
||||
if (complaint := stub_without_owner(relative, (REPO_ROOT / relative).read_text("utf-8")))
|
||||
]
|
||||
report = "\n ".join(complaints)
|
||||
assert complaints == [], f"documents that are not self-describing:\n {report}"
|
||||
|
||||
|
||||
def test_readme_and_agents_reference_only_honest_doc_paths() -> None:
|
||||
"""A doc path in README/AGENTS either exists or is owed by a phase that has not run."""
|
||||
completed = latest_completed_phase()
|
||||
rows = documentation_index()
|
||||
owed_later = {row.path for row in rows if row.promised_from > completed}
|
||||
owed_later |= roadmap_promised_docs()
|
||||
root_docs = {row.path for row in rows if not row.path.startswith("docs/")}
|
||||
references: dict[str, set[str]] = {}
|
||||
for name in ("README.md", "AGENTS.md"):
|
||||
text = (REPO_ROOT / name).read_text(encoding="utf-8")
|
||||
references[name] = set(DOC_REFERENCE.findall(text)) | {
|
||||
path for path in root_docs if path in text
|
||||
}
|
||||
offenders = dishonest_references(
|
||||
references, exists=lambda path: (REPO_ROOT / path).is_file(), owed_later=owed_later
|
||||
)
|
||||
report = "\n ".join(offenders)
|
||||
assert offenders == [], f"references to documentation nothing promises to create:\n {report}"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- the guard itself
|
||||
|
||||
|
||||
def test_a_missing_promised_doc_is_named(tmp_path: Path) -> None:
|
||||
"""Plant a row whose phase already shipped: the parse must expose it as owed."""
|
||||
plan = tmp_path / "PLAN.md"
|
||||
plan.write_text(
|
||||
"\n".join(
|
||||
[
|
||||
"## Risks",
|
||||
"",
|
||||
INDEX_HEADING,
|
||||
"",
|
||||
"| Path | Contents | Created by |",
|
||||
"|---|---|---|",
|
||||
"| `docs/GONE.md` | whatever it says | phase 01 |",
|
||||
"| `docs/LATER.md` | not owed yet | phase 09 |",
|
||||
"",
|
||||
"prose after the table",
|
||||
]
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
rows = parse_documentation_index(plan.read_text(encoding="utf-8"))
|
||||
assert [row.path for row in rows] == ["docs/GONE.md", "docs/LATER.md"]
|
||||
owed = [row.path for row in rows if row.promised_from <= 1]
|
||||
assert owed == ["docs/GONE.md"]
|
||||
assert not (tmp_path / owed[0]).is_file()
|
||||
|
||||
|
||||
def test_an_unpromised_doc_file_is_reported(tmp_path: Path) -> None:
|
||||
"""The orphan rule works on a temp tree too — it is not README-specific magic."""
|
||||
(tmp_path / "docs").mkdir()
|
||||
(tmp_path / "docs" / "WANTED.md").write_text("# WANTED\n", encoding="utf-8")
|
||||
(tmp_path / "docs" / "MYSTERY.md").write_text("# MYSTERY\n", encoding="utf-8")
|
||||
(tmp_path / "docs" / "tile.svg").write_text("<svg/>", encoding="utf-8")
|
||||
assert written_docs(tmp_path, root=tmp_path) == {
|
||||
"docs/MYSTERY.md",
|
||||
"docs/WANTED.md",
|
||||
}
|
||||
assert written_docs(tmp_path, root=tmp_path) - {"docs/WANTED.md"} == {"docs/MYSTERY.md"}
|
||||
|
||||
|
||||
def test_an_unhonest_document_is_described_precisely() -> None:
|
||||
"""Each complaint the stub check can make is exercised on planted text."""
|
||||
body = "word " * 30
|
||||
good = f"# Title\n\n## Purpose\n{body}\nA stub here. Filled in by phase 09.\n"
|
||||
assert stub_without_owner("docs/GOOD.md", good) is None
|
||||
assert stub_without_owner("docs/ORPHAN.md", good.replace(" Filled in by phase 09.", "")) == (
|
||||
"docs/ORPHAN.md is a stub with no 'Filled in by phase NN' marker"
|
||||
)
|
||||
assert stub_without_owner("docs/UNTITLED.md", f"## Purpose\n{body}") == (
|
||||
"docs/UNTITLED.md does not open with a title"
|
||||
)
|
||||
assert stub_without_owner("docs/NOPURPOSE.md", "# Title\nProse only.\n") == (
|
||||
"docs/NOPURPOSE.md has no Purpose section"
|
||||
)
|
||||
assert stub_without_owner("docs/THIN.md", "# Title\n\n## Purpose\ntoo short\n") == (
|
||||
"docs/THIN.md's Purpose paragraph is too thin to guide"
|
||||
)
|
||||
|
||||
|
||||
def test_a_reference_to_a_doc_nobody_promises_is_named() -> None:
|
||||
references = {
|
||||
"AGENTS.md": {"docs/HERE.md"},
|
||||
"README.md": {"docs/HERE.md", "docs/GHOST.md", "docs/OWED.md"},
|
||||
}
|
||||
offenders = dishonest_references(
|
||||
references,
|
||||
exists=lambda path: path in {"docs/HERE.md", "docs/OWED.md"},
|
||||
owed_later={"docs/OWED.md"},
|
||||
)
|
||||
assert offenders == ["README.md: docs/GHOST.md"]
|
||||
|
||||
|
||||
def test_the_roadmap_extension_of_the_doc_set_is_tracked() -> None:
|
||||
"""`docs/ROOMS.md` is phase 05's, not PLAN §13's — and must not read as an orphan."""
|
||||
roadmap = roadmap_promised_docs()
|
||||
assert "docs/ROOMS.md" in roadmap
|
||||
assert "docs/ROOMS.md" not in CANONICAL_DOCS
|
||||
assert latest_completed_phase() >= 1
|
||||
@@ -0,0 +1,90 @@
|
||||
"""Phase-01 task-01 harness smoke tests: package metadata and pytest wiring."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
import undercooked
|
||||
|
||||
pytestmark = pytest.mark.unit
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
HARNESS_MARKERS = ("unit", "integration", "e2e", "slow")
|
||||
TEST_SUBDIRS = ("unit", "integration", "sim", "e2e")
|
||||
|
||||
|
||||
def test_package_metadata() -> None:
|
||||
assert undercooked.__version__ == "0.1.0"
|
||||
assert undercooked.__all__ == []
|
||||
|
||||
|
||||
def test_harness_markers_registered(pytestconfig: pytest.Config) -> None:
|
||||
registered = {line.split(":", 1)[0].strip() for line in pytestconfig.getini("markers")}
|
||||
assert set(HARNESS_MARKERS) <= registered
|
||||
|
||||
|
||||
def test_strict_markers_enabled(pytestconfig: pytest.Config) -> None:
|
||||
assert "--strict-markers" in pytestconfig.getini("addopts")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", TEST_SUBDIRS)
|
||||
def test_test_tree_directories_exist_with_readme(name: str) -> None:
|
||||
directory = REPO_ROOT / "tests" / name
|
||||
assert directory.is_dir()
|
||||
readme = directory / "README.md"
|
||||
assert readme.is_file()
|
||||
assert readme.read_text(encoding="utf-8").strip()
|
||||
|
||||
|
||||
def test_tests_dir_is_not_a_package() -> None:
|
||||
assert not (REPO_ROOT / "tests" / "__init__.py").exists()
|
||||
|
||||
|
||||
def test_fixture_levels_dir_exists(isolated_levels_dir: None) -> None:
|
||||
levels_dir = Path(os.environ["UNDERCOOKED_LEVELS_DIR"])
|
||||
assert levels_dir == REPO_ROOT / "tests" / "fixtures" / "levels"
|
||||
assert levels_dir.is_dir()
|
||||
assert not list(levels_dir.glob("*.yaml"))
|
||||
|
||||
|
||||
def test_e2e_tests_follow_the_story_naming_convention() -> None:
|
||||
"""`pytest -m e2e --collect-only` selects only `test_usNN_*.py` story suites.
|
||||
|
||||
Task 01 could only assert "the marker collects nothing yet". Now that the first
|
||||
browser suite exists the durable invariant is the naming contract in
|
||||
`tests/e2e/README.md`: a story suite that is not named for its story is one that
|
||||
nobody can find, and one that cannot be collected is one that never runs.
|
||||
"""
|
||||
result = subprocess.run( # fixed argv, repo's own interpreter
|
||||
[
|
||||
sys.executable,
|
||||
"-m",
|
||||
"pytest",
|
||||
"-m",
|
||||
"e2e",
|
||||
"--collect-only",
|
||||
"-q",
|
||||
"-o",
|
||||
"addopts=", # drop the outer coverage flags: no nested .coverage writes
|
||||
"-p",
|
||||
"no:cacheprovider",
|
||||
],
|
||||
cwd=REPO_ROOT,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
timeout=120,
|
||||
)
|
||||
assert result.returncode == pytest.ExitCode.OK, result.stdout + result.stderr
|
||||
assert "error" not in result.stderr.lower(), result.stderr
|
||||
files = sorted({line.split("::")[0] for line in result.stdout.splitlines() if "::" in line})
|
||||
assert files, f"the e2e marker selected nothing:\n{result.stdout}"
|
||||
for path in files:
|
||||
assert path.startswith("tests/e2e/"), path
|
||||
assert re.fullmatch(r"test_us\d{2}_.+\.py", Path(path).name), path
|
||||
@@ -0,0 +1,105 @@
|
||||
"""Unit tests for the security headers (PLAN.md §11, A12).
|
||||
|
||||
These assert the *values*, not just their presence: a header that silently
|
||||
weakened (a `unsafe-inline`, a `*`, a reporting endpoint) is a security
|
||||
regression dressed up as a passing test.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from starlette.responses import JSONResponse, PlainTextResponse, Response
|
||||
|
||||
from undercooked.server.headers import (
|
||||
CONTENT_SECURITY_POLICY,
|
||||
SECURITY_HEADERS,
|
||||
SecurityHeadersMiddleware,
|
||||
apply_security_headers,
|
||||
)
|
||||
|
||||
pytestmark = pytest.mark.unit
|
||||
|
||||
EXPECTED = {
|
||||
"Content-Security-Policy": CONTENT_SECURITY_POLICY,
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
"Referrer-Policy": "no-referrer",
|
||||
"X-Frame-Options": "DENY",
|
||||
"Permissions-Policy": "camera=(), microphone=(), geolocation=()",
|
||||
}
|
||||
|
||||
|
||||
def test_the_five_documented_headers_are_defined() -> None:
|
||||
assert dict(SECURITY_HEADERS) == EXPECTED
|
||||
|
||||
|
||||
def test_apply_adds_every_header_to_a_response() -> None:
|
||||
response = PlainTextResponse("hi")
|
||||
apply_security_headers(response)
|
||||
for header, value in EXPECTED.items():
|
||||
assert response.headers[header] == value
|
||||
|
||||
|
||||
def test_apply_works_on_json_and_empty_bodies() -> None:
|
||||
payload = JSONResponse({"a": 1})
|
||||
apply_security_headers(payload)
|
||||
assert payload.headers["content-security-policy"] == CONTENT_SECURITY_POLICY
|
||||
|
||||
empty = Response(status_code=204)
|
||||
apply_security_headers(empty)
|
||||
assert empty.headers["x-content-type-options"] == "nosniff"
|
||||
|
||||
|
||||
def test_apply_is_idempotent_and_overrides_weaker_values() -> None:
|
||||
response = PlainTextResponse("hi", headers={"X-Frame-Options": "SAMEORIGIN"})
|
||||
apply_security_headers(response)
|
||||
apply_security_headers(response)
|
||||
assert response.headers["x-frame-options"] == "DENY"
|
||||
|
||||
|
||||
def _csp() -> dict[str, str]:
|
||||
"""The CSP as `directive -> whole clause`, so assertions read like the header."""
|
||||
return {part.split(" ", 1)[0]: part for part in CONTENT_SECURITY_POLICY.split("; ")}
|
||||
|
||||
|
||||
def test_csp_locks_scripts_and_styles_to_self() -> None:
|
||||
directives = _csp()
|
||||
assert directives["default-src"] == "default-src 'self'"
|
||||
assert directives["script-src"] == "script-src 'self'"
|
||||
assert directives["style-src"] == "style-src 'self'"
|
||||
assert directives["font-src"] == "font-src 'self'"
|
||||
assert directives["base-uri"] == "base-uri 'none'"
|
||||
assert directives["frame-ancestors"] == "frame-ancestors 'none'"
|
||||
|
||||
|
||||
def test_csp_allows_only_same_origin_websockets_and_data_images() -> None:
|
||||
directives = _csp()
|
||||
assert directives["connect-src"] == "connect-src 'self' ws: wss:"
|
||||
assert directives["img-src"] == "img-src 'self' data:"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"forbidden", ["unsafe-inline", "unsafe-eval", " https://", "http://", "*", "report-uri"]
|
||||
)
|
||||
def test_csp_contains_no_unsafe_source_or_remote_endpoint(forbidden: str) -> None:
|
||||
assert forbidden not in CONTENT_SECURITY_POLICY
|
||||
|
||||
|
||||
def test_no_header_points_at_a_remote_reporting_service() -> None:
|
||||
"""`Report-To`/`report-uri` would phone a third party: A12 forbids it."""
|
||||
assert not any(name.lower() in {"report-to", "nel"} for name in SECURITY_HEADERS)
|
||||
for value in SECURITY_HEADERS.values():
|
||||
assert "://" not in value
|
||||
assert "report" not in value.lower()
|
||||
|
||||
|
||||
def test_headers_are_immutable() -> None:
|
||||
with pytest.raises(TypeError):
|
||||
SECURITY_HEADERS["X-Frame-Options"] = "ALLOWALL" # type: ignore[index]
|
||||
|
||||
|
||||
def test_middleware_is_constructible_with_an_app_stub() -> None:
|
||||
async def stub_app(scope: object, receive: object, send: object) -> None: # pragma: no cover
|
||||
return None
|
||||
|
||||
middleware = SecurityHeadersMiddleware(stub_app)
|
||||
assert middleware.app is stub_app
|
||||
@@ -0,0 +1,257 @@
|
||||
"""Unit tests for the `undercooked` CLI (task 03): precedence, exit codes, wiring.
|
||||
|
||||
`_resolve` and `_uvicorn_log_config` are pure, so precedence is testable without
|
||||
a socket. `main()` is tested with uvicorn's `run` replaced by a recorder — the
|
||||
real server is started by the integration/phase criteria, not by a unit test.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import pytest
|
||||
import uvicorn
|
||||
import uvicorn.config
|
||||
|
||||
from undercooked.__main__ import (
|
||||
APP_IMPORT_STRING,
|
||||
EXIT_CONFIG_ERROR,
|
||||
EXIT_OK,
|
||||
RunOptions,
|
||||
_cli_overrides,
|
||||
_resolve,
|
||||
_uvicorn_log_config,
|
||||
build_parser,
|
||||
main,
|
||||
)
|
||||
from undercooked.config import (
|
||||
ENV_KEYS,
|
||||
ConfigError,
|
||||
Settings,
|
||||
apply_cli_overrides,
|
||||
get_settings,
|
||||
reset_settings_cache,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Iterator
|
||||
|
||||
pytestmark = pytest.mark.unit
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
# A string in an assertion, never a socket bind: the CLI flag is what is under test.
|
||||
ALL_INTERFACES = "0.0.0.0" # noqa: S104 - a test assertion, not a socket bind
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def pristine_env() -> Iterator[None]:
|
||||
"""`main()` publishes CLI flags into `os.environ`; nothing may leak out."""
|
||||
saved = dict(os.environ)
|
||||
reset_settings_cache()
|
||||
yield
|
||||
os.environ.clear()
|
||||
os.environ.update(saved)
|
||||
reset_settings_cache()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def uvicorn_calls(monkeypatch: pytest.MonkeyPatch) -> list[dict[str, Any]]:
|
||||
"""Replace uvicorn's `run` with a recorder; returns the recorded invocations."""
|
||||
calls: list[dict[str, Any]] = []
|
||||
|
||||
def fake_run(target: str, **kwargs: Any) -> None:
|
||||
calls.append({"target": target, **kwargs})
|
||||
|
||||
monkeypatch.setattr(uvicorn, "run", fake_run)
|
||||
return calls
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def bare_env(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""No `UNDERCOOKED_*`/`DEBUGPY*` in the environment: defaults are observable."""
|
||||
for key in list(os.environ):
|
||||
if key.startswith(("UNDERCOOKED_", "DEBUGPY")):
|
||||
monkeypatch.delenv(key, raising=False)
|
||||
monkeypatch.setenv("PYTHON_DOTENV_DISABLED", "true")
|
||||
|
||||
|
||||
def parse(argv: list[str]) -> Any:
|
||||
"""Parse ``argv`` the way `main` would, without serving anything."""
|
||||
return build_parser().parse_args(argv)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------- parser
|
||||
|
||||
|
||||
def test_every_flag_defaults_to_unset() -> None:
|
||||
args = parse([])
|
||||
assert (args.host, args.port, args.reload, args.levels_dir) == (None, None, False, None)
|
||||
|
||||
|
||||
def test_flags_are_parsed() -> None:
|
||||
args = parse(
|
||||
["--host", ALL_INTERFACES, "--port", "9000", "--reload", "--levels-dir", "kitchens"]
|
||||
)
|
||||
assert (args.host, args.port, args.reload) == (ALL_INTERFACES, 9000, True)
|
||||
assert args.levels_dir == Path("kitchens")
|
||||
|
||||
|
||||
def test_help_names_the_env_fallback() -> None:
|
||||
text = build_parser().format_help()
|
||||
assert "--levels-dir" in text
|
||||
assert "UNDERCOOKED_PORT" in text
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------- precedence
|
||||
|
||||
|
||||
def test_cli_beats_environment_and_defaults() -> None:
|
||||
settings = Settings(host="10.0.0.1", port=8123, levels_dir=Path("env-levels"))
|
||||
options = _resolve(
|
||||
parse(["--host", "127.0.0.1", "--port", "9999", "--levels-dir", "cli-levels"]), settings
|
||||
)
|
||||
assert options == RunOptions(
|
||||
host="127.0.0.1", port=9999, reload=False, levels_dir=Path("cli-levels")
|
||||
)
|
||||
|
||||
|
||||
def test_settings_supply_everything_the_cli_left_unset() -> None:
|
||||
settings = Settings(host=ALL_INTERFACES, port=8123, levels_dir=Path("env-levels"))
|
||||
options = _resolve(parse([]), settings)
|
||||
assert options == RunOptions(
|
||||
host=ALL_INTERFACES, port=8123, reload=False, levels_dir=Path("env-levels")
|
||||
)
|
||||
|
||||
|
||||
def test_settings_default_to_the_documented_defaults_in_a_bare_environment(
|
||||
bare_env: None,
|
||||
) -> None:
|
||||
options = _resolve(parse([]), get_settings())
|
||||
assert options == RunOptions(
|
||||
host="127.0.0.1", port=8000, reload=False, levels_dir=Path("levels")
|
||||
)
|
||||
|
||||
|
||||
def test_environment_beats_the_builtin_default(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("UNDERCOOKED_PORT", "8123")
|
||||
monkeypatch.setenv("UNDERCOOKED_HOST", ALL_INTERFACES)
|
||||
reset_settings_cache()
|
||||
options = _resolve(parse([]), get_settings())
|
||||
assert (options.host, options.port) == (ALL_INTERFACES, 8123)
|
||||
|
||||
|
||||
def test_reload_is_a_flag_not_an_environment_variable() -> None:
|
||||
assert _resolve(parse(["--reload"]), Settings()).reload is True
|
||||
assert _resolve(parse([]), Settings()).reload is False
|
||||
|
||||
|
||||
def test_cli_overrides_only_report_flags_that_were_given() -> None:
|
||||
assert _cli_overrides(parse([])) == {}
|
||||
assert _cli_overrides(parse(["--port", "9000", "--levels-dir", "x"])) == {
|
||||
"port": 9000,
|
||||
"levels_dir": Path("x"),
|
||||
}
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------- main()
|
||||
|
||||
|
||||
def test_main_serves_the_app_string_with_resolved_options(
|
||||
uvicorn_calls: list[dict[str, Any]], bare_env: None
|
||||
) -> None:
|
||||
assert main([]) == EXIT_OK
|
||||
assert len(uvicorn_calls) == 1
|
||||
call = uvicorn_calls[0]
|
||||
assert call["target"] == APP_IMPORT_STRING # reload-capable "module:attribute" form
|
||||
assert (call["host"], call["port"], call["reload"]) == ("127.0.0.1", 8000, False)
|
||||
assert "undercooked" in call["log_config"]["loggers"]
|
||||
|
||||
|
||||
def test_main_passes_cli_flags_to_uvicorn(
|
||||
uvicorn_calls: list[dict[str, Any]], bare_env: None
|
||||
) -> None:
|
||||
assert main(["--host", ALL_INTERFACES, "--port", "9123", "--reload"]) == EXIT_OK
|
||||
call = uvicorn_calls[0]
|
||||
assert (call["host"], call["port"], call["reload"]) == (ALL_INTERFACES, 9123, True)
|
||||
|
||||
|
||||
def test_main_publishes_cli_flags_to_the_environment(
|
||||
uvicorn_calls: list[dict[str, Any]], bare_env: None
|
||||
) -> None:
|
||||
"""A uvicorn --reload child re-reads the environment, so flags must live there."""
|
||||
assert main(["--levels-dir", "kitchens", "--port", "9124"]) == EXIT_OK
|
||||
assert os.environ[ENV_KEYS["levels_dir"]] == "kitchens"
|
||||
assert os.environ[ENV_KEYS["port"]] == "9124"
|
||||
assert get_settings().levels_dir == Path("kitchens")
|
||||
assert get_settings().port == 9124
|
||||
|
||||
|
||||
def test_main_validates_cli_values_like_environment_values(
|
||||
uvicorn_calls: list[dict[str, Any]], capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
assert main(["--port", "0"]) == EXIT_CONFIG_ERROR
|
||||
assert uvicorn_calls == []
|
||||
err = capsys.readouterr().err
|
||||
assert err.strip().startswith("undercooked:")
|
||||
assert "UNDERCOOKED_PORT" in err
|
||||
assert err.count("\n") == 1 # a one-line message, not a traceback
|
||||
|
||||
|
||||
def test_main_reports_an_invalid_environment(
|
||||
uvicorn_calls: list[dict[str, Any]],
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
monkeypatch.setenv("UNDERCOOKED_TICK_HZ", "99")
|
||||
reset_settings_cache()
|
||||
assert main([]) == EXIT_CONFIG_ERROR
|
||||
assert uvicorn_calls == []
|
||||
assert "UNDERCOOKED_TICK_HZ=99" in capsys.readouterr().err
|
||||
|
||||
|
||||
def test_unknown_flag_is_an_argument_error() -> None:
|
||||
with pytest.raises(SystemExit) as excinfo:
|
||||
main(["--nope"])
|
||||
assert excinfo.value.code == EXIT_CONFIG_ERROR
|
||||
|
||||
|
||||
def test_an_unknown_settings_field_is_a_config_error() -> None:
|
||||
with pytest.raises(ConfigError, match="not a configuration field"):
|
||||
apply_cli_overrides(**{"nonsense": 1})
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------- logging
|
||||
|
||||
|
||||
def test_uvicorn_log_config_adds_our_loggers_without_touching_uvicorns() -> None:
|
||||
base = copy.deepcopy(uvicorn.config.LOGGING_CONFIG)
|
||||
config = _uvicorn_log_config(base)
|
||||
assert config is not base
|
||||
assert set(base["loggers"]) == {"uvicorn", "uvicorn.error", "uvicorn.access"}
|
||||
ours = config["loggers"]["undercooked"]
|
||||
assert ours["handlers"] == ["default"]
|
||||
assert ours["level"] == "INFO"
|
||||
assert config["loggers"]["uvicorn"] is base["loggers"]["uvicorn"]
|
||||
assert set(config["formatters"]) == set(base["formatters"]) # the handler exists
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------- the console script
|
||||
|
||||
|
||||
def test_python_m_undercooked_prints_usage_and_exits_zero() -> None:
|
||||
result = subprocess.run( # fixed argv, the repo's own interpreter
|
||||
[sys.executable, "-m", "undercooked", "--help"],
|
||||
cwd=REPO_ROOT,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
timeout=120,
|
||||
)
|
||||
assert result.returncode == EXIT_OK, result.stderr
|
||||
assert "usage: undercooked" in result.stdout
|
||||
assert "--reload" in result.stdout
|
||||
@@ -0,0 +1,392 @@
|
||||
"""PLAN A12 guard: the browser must load nothing this process does not serve.
|
||||
|
||||
This is the mechanical half of "no external CDNs" (LOCKED decision A12, AGENTS.md §5).
|
||||
It has two halves on purpose:
|
||||
|
||||
1. **A text scan** of every `app/static/**` asset (`.html`, `.css`, `.js`, `.svg`) for the
|
||||
shapes that reach off-origin: `http(s)://`, protocol-relative `//host/`, hosted-library
|
||||
mirrors (`jsdelivr`, `unpkg`, …), CSS `@import url(`, protocol-relative stylesheets,
|
||||
`srcset`, `fetch()` / `import` of an absolute URL, an absolute-URL `WebSocket`, and
|
||||
`EventSource` (SSE is not this project's transport). A single remote reference is both
|
||||
an availability and a privacy defect, so the scan is blunt and needs no allow-list —
|
||||
with one documented exception, the `xmlns` namespace URIs of SVG/XML, which are
|
||||
identifiers the browser never fetches (A11 ships in-repo SVG art).
|
||||
|
||||
2. **An element check** of `index.html`: every `<script src>`, `<link href>` and
|
||||
`<img src>` must be a same-origin `/static/...` path (a `data:` URL is allowed where
|
||||
the CSP allows it — `img-src 'self' data:` — because it is the app's own bytes).
|
||||
|
||||
The root it scans is imported from `undercooked.server.app.STATIC_DIR`, i.e. the very
|
||||
directory the server mounts at `/static`, so the guard cannot pass by scanning some other
|
||||
folder. The last group of tests plants each forbidden shape in a temporary tree and
|
||||
asserts the scan names the offending file, which is what keeps this file from rotting
|
||||
into a test that scans nothing.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from collections.abc import Iterator
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from undercooked.config import Settings
|
||||
from undercooked.server.app import STATIC_DIR, resolve_static_dir
|
||||
|
||||
pytestmark = pytest.mark.unit
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
#: Every file type a browser can be told to load or parse.
|
||||
ASSET_SUFFIXES = frozenset({".html", ".css", ".js", ".svg"})
|
||||
|
||||
#: Files whose text may name a hosted library. CSS/SVG are covered by the URL rules below;
|
||||
#: HTML and JS are where a `<script>`/`import` of a hosted bundle actually shows up.
|
||||
HOSTED_TOKEN_SUFFIXES = frozenset({".html", ".js"})
|
||||
|
||||
#: Hosted-library / hosted-font mirrors that must never appear in shipped client text.
|
||||
HOSTED_LIBRARY_TOKENS = ("googleapis", "gstatic", "jsdelivr", "unpkg", "cdnjs", "tailwindcss")
|
||||
|
||||
#: An absolute or protocol-relative authority in a URL-ish position.
|
||||
_ABS = r"(?:https?:)?//"
|
||||
|
||||
#: XML namespace declarations: identifiers, not requests (SVG art is first-class, A11).
|
||||
_NAMESPACE_DECL = re.compile(
|
||||
r"""xmlns(?::[\w.-]+)?\s*=\s*["']?https?://www\.w3\.org/[^\s"'<>]*["']?""",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
#: (rule name, pattern) pairs applied to each masked line of every asset.
|
||||
BANNED_PATTERNS: tuple[tuple[str, re.Pattern[str]], ...] = (
|
||||
(
|
||||
"absolute URL",
|
||||
re.compile(r"https?://", re.IGNORECASE),
|
||||
),
|
||||
(
|
||||
"protocol-relative URL",
|
||||
# `//host.tld/...` — the scheme is deliberately NOT part of this pattern, so the
|
||||
# lookbehind really does keep `https://` from being reported twice, and the
|
||||
# host-shaped lookahead keeps JS/CSS comments (`// note`) from matching at all.
|
||||
re.compile(r"(?<![A-Za-z0-9:])//(?![\s/])(?:[\w-]+\.)+[a-z]{2,}", re.IGNORECASE),
|
||||
),
|
||||
(
|
||||
"CSS @import url()",
|
||||
re.compile(r"@import\s+url\s*\(", re.IGNORECASE),
|
||||
),
|
||||
(
|
||||
"protocol-relative stylesheet",
|
||||
re.compile(
|
||||
rf"""<link\b[^>]*?rel=["']?[^"'<>]*stylesheet[^>"']*["']?[^>]*?href=["']?{_ABS}""",
|
||||
re.IGNORECASE,
|
||||
),
|
||||
),
|
||||
(
|
||||
"srcset attribute",
|
||||
re.compile(r"\bsrcset\s*=", re.IGNORECASE),
|
||||
),
|
||||
(
|
||||
"fetch() of an off-origin URL",
|
||||
re.compile(rf"\bfetch\s*\(\s*[\"'`]{_ABS}", re.IGNORECASE),
|
||||
),
|
||||
(
|
||||
"import of an off-origin URL",
|
||||
re.compile(rf"\b(?:import|export)\b[^;\n]*?\bfrom\s*[\"'`]{_ABS}", re.IGNORECASE),
|
||||
),
|
||||
(
|
||||
"bare import of an off-origin URL",
|
||||
re.compile(rf"\bimport\s*[\"'`]{_ABS}", re.IGNORECASE),
|
||||
),
|
||||
(
|
||||
"WebSocket with an absolute URL",
|
||||
# Same-origin is built from `location`, never written out (`WebSocket("ws…`).
|
||||
re.compile(r"\bWebSocket\s*\(\s*[\"'`](?:wss?|https?)://", re.IGNORECASE),
|
||||
),
|
||||
(
|
||||
"EventSource (server-sent events)",
|
||||
re.compile(r"\bnew\s+EventSource\s*\(|\bEventSource\s*\(", re.IGNORECASE),
|
||||
),
|
||||
)
|
||||
|
||||
#: Same-origin prefixes an element URL may start with.
|
||||
STATIC_PREFIX = "/static/"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Violation:
|
||||
"""One forbidden reference, in one file, on one line."""
|
||||
|
||||
path: Path
|
||||
line: int
|
||||
rule: str
|
||||
text: str
|
||||
|
||||
def describe(self) -> str:
|
||||
"""Human-readable failure line naming the offending file."""
|
||||
return f"{self.path}:{self.line}: {self.rule}: {self.text.strip()}"
|
||||
|
||||
|
||||
def _mask(text: str) -> str:
|
||||
"""Blank out the text that looks external but cannot cause a request."""
|
||||
return _NAMESPACE_DECL.sub(lambda match: " " * len(match.group(0)), text)
|
||||
|
||||
|
||||
def iter_asset_files(root: Path) -> Iterator[Path]:
|
||||
"""Every scannable asset under ``root``, depth-first and in a stable order."""
|
||||
if not root.is_dir(): # pragma: no cover - guarded by the caller's own assertion
|
||||
return
|
||||
for path in sorted(root.rglob("*")):
|
||||
if path.is_file() and path.suffix.lower() in ASSET_SUFFIXES:
|
||||
yield path
|
||||
|
||||
|
||||
def scan_text(path: Path, text: str) -> list[Violation]:
|
||||
"""Every forbidden reference in ``text``, attributed to ``path``."""
|
||||
violations: list[Violation] = []
|
||||
for number, raw in enumerate(_mask(text).splitlines(), start=1):
|
||||
for rule, pattern in BANNED_PATTERNS:
|
||||
if pattern.search(raw):
|
||||
violations.append(Violation(path=path, line=number, rule=rule, text=raw))
|
||||
if path.suffix.lower() in HOSTED_TOKEN_SUFFIXES:
|
||||
lowered = raw.lower()
|
||||
violations.extend(
|
||||
Violation(path=path, line=number, rule=f"hosted library {token!r}", text=raw)
|
||||
for token in HOSTED_LIBRARY_TOKENS
|
||||
if token in lowered
|
||||
)
|
||||
if path.suffix.lower() == ".html":
|
||||
violations.extend(scan_element_urls(path, text))
|
||||
return violations
|
||||
|
||||
|
||||
def scan_file(path: Path) -> list[Violation]:
|
||||
"""Read one asset and return its forbidden references."""
|
||||
return scan_text(path, path.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def scan_tree(root: Path) -> list[Violation]:
|
||||
"""Scan every asset under ``root`` (the shipped client, when called with STATIC_DIR)."""
|
||||
return [violation for path in iter_asset_files(root) for violation in scan_file(path)]
|
||||
|
||||
|
||||
def _element_attributes(tag: str) -> dict[str, str]:
|
||||
"""Parse `name="value"` pairs of one tag into a lower-cased mapping."""
|
||||
found: dict[str, str] = {}
|
||||
for name, value in re.findall(r"""([\w-]+)\s*=\s*["']([^"']*)["']""", tag):
|
||||
found[name.lower()] = value
|
||||
return found
|
||||
|
||||
|
||||
def _is_allowed(value: str, *, data_ok: bool) -> bool:
|
||||
if value.startswith(STATIC_PREFIX):
|
||||
return True
|
||||
return data_ok and value.startswith("data:")
|
||||
|
||||
|
||||
def scan_element_urls(path: Path, text: str) -> list[Violation]:
|
||||
"""Every `<script src>` / `<link href>` / `<img src>` in an HTML document that this
|
||||
app does not serve.
|
||||
|
||||
`<script src>` and stylesheet `<link>`s must be `/static/...`. `<img>` and
|
||||
non-stylesheet `<link>`s (the `data:` favicon) may additionally be `data:`, matching
|
||||
the CSP's `img-src 'self' data:` — in-app bytes either way.
|
||||
"""
|
||||
violations: list[Violation] = []
|
||||
for match in re.finditer(r"<(script|link|img)\b((?:[^>'\"]|\"[^\"]*\"|'[^']*')*)>", text, re.I):
|
||||
name = match.group(1).lower()
|
||||
attrs = _element_attributes(match.group(2))
|
||||
if name == "script":
|
||||
attribute, data_ok = "src", False
|
||||
elif name == "link":
|
||||
attribute, data_ok = "href", "stylesheet" not in attrs.get("rel", "").lower()
|
||||
else:
|
||||
attribute, data_ok = "src", True
|
||||
value = attrs.get(attribute)
|
||||
if value is None or _is_allowed(value, data_ok=data_ok):
|
||||
continue
|
||||
line = text.count("\n", 0, match.start()) + 1
|
||||
rule = f"<{name} {attribute}> is not a same-origin /static/ path"
|
||||
violations.append(Violation(path=path, line=line, rule=rule, text=match.group(0)))
|
||||
return violations
|
||||
|
||||
|
||||
def format_violations(violations: list[Violation]) -> str:
|
||||
"""Render violations for a pytest failure message."""
|
||||
return "\n".join(violation.describe() for violation in violations)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def static_tree(tmp_path: Path) -> Path:
|
||||
"""A throwaway static root, shaped like the real one, for planted violations."""
|
||||
root = tmp_path / "static"
|
||||
(root / "js").mkdir(parents=True)
|
||||
(root / "css").mkdir(parents=True)
|
||||
(root / "index.html").write_text("<!doctype html><title>t</title>", encoding="utf-8")
|
||||
return root
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- the scanned root
|
||||
|
||||
|
||||
def test_the_guard_scans_the_directory_the_server_actually_serves() -> None:
|
||||
"""Re-deriving the root here is what stops a green run against the wrong folder."""
|
||||
assert STATIC_DIR == REPO_ROOT / "app" / "static"
|
||||
assert STATIC_DIR.is_dir(), f"{STATIC_DIR} is not a directory"
|
||||
assert (STATIC_DIR / "index.html").is_file()
|
||||
assert resolve_static_dir(Settings()) == STATIC_DIR
|
||||
|
||||
|
||||
def test_the_scan_walks_the_shipped_client_files() -> None:
|
||||
names = {path.relative_to(STATIC_DIR).as_posix() for path in iter_asset_files(STATIC_DIR)}
|
||||
assert {"index.html", "js/app.js", "css/base.css"} <= names, names
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- the shipped client
|
||||
|
||||
|
||||
def test_shipped_client_references_nothing_external() -> None:
|
||||
"""A12: zero forbidden references anywhere under app/static/."""
|
||||
violations = scan_tree(STATIC_DIR)
|
||||
report = format_violations(violations)
|
||||
assert violations == [], f"the client loads bytes this app does not serve:\n{report}"
|
||||
|
||||
|
||||
def test_shipped_index_html_loads_only_same_origin_assets() -> None:
|
||||
"""Non-vacuous on purpose: the index must actually declare a script and a stylesheet."""
|
||||
html = (STATIC_DIR / "index.html").read_text(encoding="utf-8")
|
||||
tags = re.findall(r"<(script|link|img)\b", html, re.I)
|
||||
assert len(tags) >= 3, "index.html no longer declares the assets this test guards"
|
||||
assert 'src="/static/js/app.js"' in html
|
||||
assert scan_element_urls(STATIC_DIR / "index.html", html) == []
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- planted violations
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("name", "content"),
|
||||
[
|
||||
("script_src", '<!doctype html><script src="https://cdn.example.com/x.js"></script>'),
|
||||
(
|
||||
"module_src",
|
||||
'<!doctype html><script type="module" src="//cdn.example.com/x.js"></script>',
|
||||
),
|
||||
("stylesheet_link", '<link rel="stylesheet" href="https://fonts.example.com/f.css">'),
|
||||
("protocol_relative_stylesheet", '<link rel="stylesheet" href="//cdn.example.com/a.css">'),
|
||||
("img_src", '<img src="https://evil.example.com/pixel.png">'),
|
||||
("img_srcset", '<img src="/static/a.svg" srcset="https://cdn.example.com/b.svg 2x">'),
|
||||
],
|
||||
)
|
||||
def test_a_planted_html_reference_fails_the_scan(
|
||||
static_tree: Path, name: str, content: str
|
||||
) -> None:
|
||||
page = static_tree / f"{name}.html"
|
||||
page.write_text(content, encoding="utf-8")
|
||||
violations = scan_tree(static_tree)
|
||||
assert violations, content
|
||||
assert all(violation.path == page for violation in violations), format_violations(violations)
|
||||
assert page.name in format_violations(violations)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("suffix", "content", "rule"),
|
||||
[
|
||||
(
|
||||
".js",
|
||||
'import { x } from "https://cdn.example.com/lib.js";',
|
||||
"import of an off-origin URL",
|
||||
),
|
||||
(
|
||||
".js",
|
||||
'import "https://cdn.example.com/side-effect.js";',
|
||||
"bare import of an off-origin URL",
|
||||
),
|
||||
(".js", 'export * from "//cdn.example.com/lib.js";', "import of an off-origin URL"),
|
||||
(".js", 'fetch("https://api.example.com/state");', "fetch() of an off-origin URL"),
|
||||
(".js", 'fetch("//api.example.com/state");', "fetch() of an off-origin URL"),
|
||||
(
|
||||
".js",
|
||||
'new WebSocket("wss://remote.example.com/ws/ABCDE");',
|
||||
"WebSocket with an absolute URL",
|
||||
),
|
||||
(".js", 'new EventSource("/events");', "EventSource (server-sent events)"),
|
||||
(".js", 'const u = "https://cdn.example.com/x.js";', "absolute URL"),
|
||||
(".css", "@import url(https://fonts.example.com/one.css);", "CSS @import url()"),
|
||||
(".css", "@import url(//fonts.example.com/one.css);", "CSS @import url()"),
|
||||
(".css", "body { background: url(//cdn.example.com/bg.png); }", "protocol-relative URL"),
|
||||
(
|
||||
".svg",
|
||||
'<svg xmlns="http://www.w3.org/2000/svg">'
|
||||
'<image href="https://x.example.com/a.png"/></svg>',
|
||||
"absolute URL",
|
||||
),
|
||||
(".html", '<a href="https://external.example.com">out</a>', "absolute URL"),
|
||||
],
|
||||
)
|
||||
def test_a_planted_asset_reference_fails_the_scan(
|
||||
static_tree: Path, suffix: str, content: str, rule: str
|
||||
) -> None:
|
||||
planted = static_tree / "js" / f"planted{suffix}"
|
||||
planted.write_text(content, encoding="utf-8")
|
||||
violations = scan_tree(static_tree)
|
||||
assert violations, content
|
||||
assert rule in {violation.rule for violation in violations}, format_violations(violations)
|
||||
assert any(violation.path == planted for violation in violations)
|
||||
assert planted.name in format_violations(violations)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("token", HOSTED_LIBRARY_TOKENS)
|
||||
@pytest.mark.parametrize("suffix", [".js", ".html"])
|
||||
def test_a_planted_hosted_library_token_fails_the_scan(
|
||||
static_tree: Path, token: str, suffix: str
|
||||
) -> None:
|
||||
planted = static_tree / f"planted{suffix}"
|
||||
planted.write_text(f"/* vendored from {token} once */", encoding="utf-8")
|
||||
violations = [v for v in scan_tree(static_tree) if v.path == planted]
|
||||
assert violations, token
|
||||
assert token in format_violations(violations)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- what must stay green
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("suffix", "content"),
|
||||
[
|
||||
(
|
||||
".svg",
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">'
|
||||
'<rect width="16" height="16"/></svg>',
|
||||
),
|
||||
(
|
||||
".svg",
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">'
|
||||
'<use xlink:href="#tile"/></svg>',
|
||||
),
|
||||
(".js", "// a comment about docs/PROTOCOL.md and a/b/c paths\nexport const ok = 1;\n"),
|
||||
(".js", 'const ws = new WebSocket(`${location.origin}/ws/${code}`);\n'),
|
||||
(".js", 'fetch("/static/api/levels");\n'),
|
||||
(".css", ".a { background: url(/static/assets/tile.svg); }\n"),
|
||||
(".html", '<img src="data:image/svg+xml,<svg/>">\n'),
|
||||
],
|
||||
)
|
||||
def test_same_origin_and_namespace_references_are_allowed(
|
||||
static_tree: Path, suffix: str, content: str
|
||||
) -> None:
|
||||
"""The guard must not train anyone to delete real code: same-origin stays green."""
|
||||
(static_tree / "js" / f"fine{suffix}").write_text(content, encoding="utf-8")
|
||||
assert scan_tree(static_tree) == []
|
||||
|
||||
|
||||
def test_non_asset_files_are_out_of_scope(static_tree: Path) -> None:
|
||||
"""Notes and images are not client instructions; the scan is about loadable code."""
|
||||
(static_tree / "NOTES.md").write_text("see https://example.com for details", encoding="utf-8")
|
||||
(static_tree / "logo.png").write_bytes(b"\x89PNG\r\n")
|
||||
assert scan_tree(static_tree) == []
|
||||
|
||||
|
||||
def test_an_empty_tree_is_not_a_passing_signal(tmp_path: Path) -> None:
|
||||
"""Documented explicitly: an empty root scans clean, so the root tests above matter."""
|
||||
assert scan_tree(tmp_path) == []
|
||||
assert list(iter_asset_files(tmp_path)) == []
|
||||
Reference in New Issue
Block a user