All green. Final verification complete — writing the report.
**Final pass report — phase 06 `realtime_websocket_protocol`**
- Verified: all 6 task files present in `complete/`; this was the final verification pass (no implementation work outstanding)
- `uv run ruff check .` → all checks passed; `uv run pyright` → 0 errors
- `uv run undercooked-levels validate` → content OK; `uv run pytest` (full suite) → **1751 passed, 0 failed, coverage 97.30% (>90% floor)**; slow sim stage (`-m slow tests/sim --no-cov`) → 2 passed; repo hygiene clean
- App launches: `/healthz` 200, `/` 200, `/api/levels`, `/api/config`, `POST /api/rooms` (201), `/api/stats` all verified live on an ephemeral port (server cleaned up)
- Criteria: scripted client room→hello→ready→start→countdown→≥20 snaps→round — PASS (`test_ws_round_two_clients.py`); identical `round` payloads both clients — PASS (`round_a == round_b`); version mismatch → `err protocol_mismatch` + close `4400` — PASS; 64 KB refused / 4 KB boundary, 500-burst → `rate_limited` → `4404` with bounded queue, 2000 malformed + 200 handshakes within memory bound — PASS; snap ≤ 1.5 KB (4p/4o/3h) and 64×64 ≤ 4 KB, downlink ≤ 60 KB/s over 100 ticks — PASS; `docs/PROTOCOL.md` model-sync test-enforced in both directions + full-round sequence diagram present — PASS
- Docs: README.md had 3 stale spots — status section (claimed 01–05 complete, 06 pending → now 01–06 complete, 07 pending), "until phases 06/07 wire the transport" wording, and the `PROTOCOL.md` layout note ("phase 06 completes it"). All fixed. AGENTS.md verified consistent — no changes needed
- No defects found in completed phases; no live infrastructure touched
Next pending phase: `07_canvas_client_and_hud`.
444 lines
17 KiB
Python
444 lines
17 KiB
Python
"""Fixtures for the live-socket integration tests (phase 06).
|
|
|
|
`ws_server` is a **session-scoped factory**: one call spins up a real
|
|
`uvicorn.Server` on an ephemeral `127.0.0.1` port (never a routable
|
|
interface, the `tests/conftest.py` rule) with a `Settings` built from
|
|
explicit overrides, and the returned context manager tears the server down
|
|
when the test ends (a second safety close runs at session end). The app
|
|
built here is yielded too, so a test can reach
|
|
`app.state.supervisor` / `app.state.ws_registry` to assert on room state
|
|
(the seat's `connected` flag, the disconnect count, the socket census)
|
|
without a second client.
|
|
|
|
`live_server` is the same machine with one difference (phase 06 task 06):
|
|
`UNDERCOOKED_LEVELS_DIR` defaults to the **shipped** `levels/` campaign
|
|
(so a live server carries the real content the content gate validated),
|
|
and any `Settings` field is overridable (`tick_hz`, `snapshot_hz`, …)
|
|
the same way. A test that needs a playable, tunable round passes
|
|
`levels_dir=` explicitly (the task-03 `levels_playable` precedent, and
|
|
the task-06 `levels_round` campaign for the two-client round).
|
|
|
|
`ws_client` is a factory for the **scripted client** the round/abuse/
|
|
budget suites drive: `ws_client(url, code, token=None, name=None)` returns
|
|
a `WsClient` — a connected `websockets` socket with the `hello` already
|
|
sent and a background reader that keeps a merged snapshot view
|
|
(`client.view`: full frames reset, delta frames patch, the frame's `e`
|
|
events accumulated) and a frame queue the client's three verbs consume:
|
|
|
|
* `send_model(model)` — queue a JSON frame out (fire-and-forget; the
|
|
`input` choreography cadence never blocks on the socket),
|
|
* `expect(type, wait_s)` — the next frame of `type`, the ones before it
|
|
buffered and re-queued for a later verb,
|
|
* `collect(type, n, wait_s)` — `n` frames of `type`, the same buffering.
|
|
|
|
Settings are built the same way `tests/conftest.py` `make_settings` does
|
|
(the authoritative `config.ENV_KEYS` contract, ambient `UNDERCOOKED_*` /
|
|
`DEBUGPY*` stripped), with one deliberate difference: `UNDERCOOKED_LEVELS_DIR`
|
|
is *kept*, pointed at the campaign the factory names (the `ws_server`
|
|
default is the fixture-isolated campaign with a recipe book — `create_app`'s
|
|
content gate passes on it; the `live_server` default is the shipped
|
|
`levels/`), so a live server boots without the ambient environment leaking.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import contextlib
|
|
import importlib
|
|
import json
|
|
import os
|
|
import threading
|
|
import time
|
|
from collections.abc import Callable, Iterator
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
import httpx
|
|
import pytest
|
|
import websockets
|
|
from websockets.exceptions import ConnectionClosed
|
|
|
|
LIVE_HOST = "127.0.0.1" # loopback only: tests never listen on a routable interface
|
|
FIXTURE_LEVELS_DIR = Path(__file__).resolve().parents[1] / "fixtures" / "levels"
|
|
#: The shipped campaign (phase 06 task 06: `live_server` boots on it).
|
|
SHIPPED_LEVELS_DIR = Path(__file__).resolve().parents[2] / "levels"
|
|
|
|
config = importlib.import_module("undercooked.config")
|
|
app_module = importlib.import_module("undercooked.server.app")
|
|
uvicorn = importlib.import_module("uvicorn")
|
|
|
|
|
|
def _as_env_value(value: object) -> str:
|
|
"""Render a Python override value the way `load_settings` reads it."""
|
|
if isinstance(value, bool):
|
|
return "1" if value else "0"
|
|
if isinstance(value, (str, os.PathLike)):
|
|
return str(value)
|
|
if isinstance(value, (list, tuple)):
|
|
return ",".join(str(item) for item in value)
|
|
return str(value)
|
|
|
|
|
|
def _wait_for_health(url: str, thread: threading.Thread) -> None:
|
|
"""Poll `/healthz` until it answers 200, failing fast if the server 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.05)
|
|
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")
|
|
|
|
|
|
def _start_live_server(
|
|
overrides: dict[str, object], default_levels_dir: Path
|
|
) -> tuple[str, Any, Any, threading.Thread]:
|
|
"""One live uvicorn server on an ephemeral loopback port.
|
|
|
|
Returns `(url, application, server, thread)`; the caller wraps them in
|
|
a `LiveServer`. `overrides` are `Settings` field names (validated
|
|
against `config.ENV_KEYS`) — `levels_dir` falls back to
|
|
`default_levels_dir` when the caller did not name it.
|
|
"""
|
|
env = {
|
|
key: value
|
|
for key, value in os.environ.items()
|
|
if not key.startswith(("UNDERCOOKED_", "DEBUGPY"))
|
|
}
|
|
levels_dir = overrides.get("levels_dir", str(default_levels_dir))
|
|
env["UNDERCOOKED_LEVELS_DIR"] = str(levels_dir)
|
|
for name, value in overrides.items():
|
|
env[config.ENV_KEYS[name]] = _as_env_value(value)
|
|
settings = config.load_settings(env)
|
|
application = app_module.create_app(settings)
|
|
uv_config = uvicorn.Config(
|
|
application,
|
|
host=LIVE_HOST,
|
|
port=0,
|
|
log_level="warning",
|
|
lifespan="on",
|
|
)
|
|
sock = uv_config.bind_socket()
|
|
url = f"http://{LIVE_HOST}:{sock.getsockname()[1]}"
|
|
server = uvicorn.Server(uv_config)
|
|
thread = threading.Thread(
|
|
target=server.run, args=([sock],), name="undercooked-live-ws", daemon=True
|
|
)
|
|
thread.start()
|
|
_wait_for_health(url, thread)
|
|
return url, application, server, thread
|
|
|
|
|
|
class LiveServer:
|
|
"""One live uvicorn server + its app (a context manager)."""
|
|
|
|
def __init__(
|
|
self,
|
|
url: str,
|
|
app: Any,
|
|
server: Any,
|
|
thread: threading.Thread,
|
|
) -> None:
|
|
self.url = url
|
|
self.app = app
|
|
self._server = server
|
|
self._thread = thread
|
|
self._closed = False
|
|
|
|
def __enter__(self) -> tuple[str, Any]:
|
|
return self.url, self.app
|
|
|
|
def __exit__(self, *exc_info: object) -> None:
|
|
self.close()
|
|
|
|
def close(self) -> None:
|
|
if self._closed:
|
|
return
|
|
self._closed = True
|
|
self._server.should_exit = True
|
|
self._thread.join(timeout=15)
|
|
if self._thread.is_alive(): # pragma: no cover - only if uvicorn stops cooperating
|
|
pytest.fail("live server did not shut down within 15 s")
|
|
|
|
|
|
def _make_live_server_factory(default_levels_dir: Path) -> Callable[..., LiveServer]:
|
|
started: list[LiveServer] = []
|
|
|
|
def _make(**overrides: object) -> LiveServer:
|
|
if any(name not in config.ENV_KEYS for name in overrides):
|
|
unknown = sorted(name for name in overrides if name not in config.ENV_KEYS)
|
|
pytest.fail(f"unknown settings override(s): {unknown}")
|
|
url, application, server, thread = _start_live_server(
|
|
dict(overrides), default_levels_dir
|
|
)
|
|
live = LiveServer(url, application, server, thread)
|
|
started.append(live)
|
|
return live
|
|
|
|
# The session-end safety close (the context managers already closed
|
|
# each server the test used; a leaked one is closed here).
|
|
def _close_all() -> None:
|
|
for live in started:
|
|
live.close()
|
|
|
|
_make.close_all = _close_all # type: ignore[attr-defined]
|
|
return _make
|
|
|
|
|
|
@pytest.fixture(scope="session")
|
|
def ws_server() -> Iterator[Any]:
|
|
"""Factory: `with ws_server(max_players=2) as (url, app): …`
|
|
|
|
Each call starts a fresh `uvicorn.Server` on an ephemeral loopback
|
|
port (bound before the serving thread starts, so the port is known
|
|
exactly — no log scraping, no collisions between concurrent runs)
|
|
with a `Settings` resolved from `**overrides` (field name, the same
|
|
names `make_settings` takes) over the fixture-isolated content.
|
|
Shutdown is requested through `should_exit` so the lifespan stops
|
|
cleanly.
|
|
"""
|
|
factory = _make_live_server_factory(FIXTURE_LEVELS_DIR)
|
|
yield factory
|
|
factory.close_all() # type: ignore[attr-defined]
|
|
|
|
|
|
@pytest.fixture(scope="session")
|
|
def live_server() -> Iterator[Any]:
|
|
"""Factory: `with live_server(tick_hz=20, snapshot_hz=20) as (url, app): …`
|
|
|
|
The task-06 live server: identical to `ws_server` except that
|
|
`UNDERCOOKED_LEVELS_DIR` points at the **shipped** `levels/` campaign
|
|
by default (override with `levels_dir=…`), so the server boots on the
|
|
real, gate-validated content; `tick_hz` / `snapshot_hz` (and every
|
|
other `Settings` field) are overridable the same way.
|
|
"""
|
|
factory = _make_live_server_factory(SHIPPED_LEVELS_DIR)
|
|
yield factory
|
|
factory.close_all() # type: ignore[attr-defined]
|
|
|
|
|
|
def _uri(url: str, code: str) -> str:
|
|
return url.replace("http://", "ws://", 1) + f"/ws/{code}"
|
|
|
|
|
|
class View:
|
|
"""The merged snapshot state a scripted client reasons about.
|
|
|
|
Full frames (`full: true`, the `FULL_SNAPSHOT_EVERY_TICKS` cadence and
|
|
the `needs_full` resync) reset the entity maps; delta frames patch
|
|
them (a `{"gone": true}` tombstone removes). The frame's `e` events
|
|
accumulate in order (a boundary's events ride that one frame — the
|
|
encoder's contract), and the scalars (`t` / `s` / `tgt` / `tl`) are
|
|
the latest frame's."""
|
|
|
|
def __init__(self) -> None:
|
|
self.players: dict[str, dict[str, Any]] = {}
|
|
self.stations: dict[tuple[Any, Any, Any], dict[str, Any]] = {}
|
|
self.orders: dict[Any, dict[str, Any]] = {}
|
|
self.events: list[dict[str, Any]] = []
|
|
self.tick = 0
|
|
self.score = 0
|
|
self.target = 0
|
|
self.time_left = 0.0
|
|
|
|
def apply(self, frame: dict[str, Any]) -> None:
|
|
# The wire's `snap` message nests the encoder's frame under
|
|
# `raw` (`SnapMsg.raw` — the message's own `p` is the protocol
|
|
# version, the frame's own `p` is its players array).
|
|
raw = frame.get("raw")
|
|
if not isinstance(raw, dict):
|
|
raw = frame
|
|
if raw.get("full"):
|
|
self.players = {
|
|
entry["i"]: entry for entry in raw.get("p", []) if not entry.get("gone")
|
|
}
|
|
self.stations = {
|
|
(entry["k"], entry["x"], entry["y"]): entry
|
|
for entry in raw.get("st", [])
|
|
if not entry.get("gone")
|
|
}
|
|
self.orders = {
|
|
entry["id"]: entry for entry in raw.get("o", []) if not entry.get("gone")
|
|
}
|
|
else:
|
|
delta = raw.get("d", {})
|
|
for entry in delta.get("p", []):
|
|
if entry.get("gone"):
|
|
self.players.pop(entry["i"], None)
|
|
else:
|
|
self.players[entry["i"]] = entry
|
|
for entry in delta.get("st", []):
|
|
key = (entry["k"], entry["x"], entry["y"])
|
|
if entry.get("gone"):
|
|
self.stations.pop(key, None)
|
|
else:
|
|
self.stations[key] = entry
|
|
for entry in delta.get("o", []):
|
|
if entry.get("gone"):
|
|
self.orders.pop(entry["id"], None)
|
|
else:
|
|
self.orders[entry["id"]] = entry
|
|
self.events.extend(raw.get("e", []))
|
|
self.tick = raw.get("t", self.tick)
|
|
self.score = raw.get("s", self.score)
|
|
self.target = raw.get("tgt", self.target)
|
|
self.time_left = raw.get("tl", self.time_left)
|
|
|
|
def player(self, index: str) -> dict[str, Any] | None:
|
|
return self.players.get(index)
|
|
|
|
def station(self, kind: str) -> dict[str, Any] | None:
|
|
"""The first station of `kind` (the wire's identity is `k` +
|
|
coordinates; a level's stations of a kind sit on distinct cells)."""
|
|
for (station_kind, _, _), entry in self.stations.items():
|
|
if station_kind == kind:
|
|
return entry
|
|
return None
|
|
|
|
def station_at(self, kind: str, x: float, y: float) -> dict[str, Any] | None:
|
|
return self.stations.get((kind, x, y))
|
|
|
|
def event(self, kind: str) -> dict[str, Any] | None:
|
|
for entry in self.events:
|
|
if entry.get("k") == kind:
|
|
return entry
|
|
return None
|
|
|
|
|
|
class WsClient:
|
|
"""The scripted client (phase 06 task 06): a connected socket with the
|
|
`hello` already sent, a background reader (the merged `view` + the
|
|
frame queue), and the three verbs the suites name.
|
|
|
|
`send_model` is fire-and-forget by design: the choreography feeds
|
|
inputs on a cadence (~10 Hz) faster than the socket's round trip, and
|
|
the room's per-tick queue is what serialises the intent — a blocking
|
|
send would add latency the sim's queue discipline is meant to absorb.
|
|
`raw` is the malformed-frame half (the abuse suite's `not json`).
|
|
"""
|
|
|
|
def __init__(self, ws: Any) -> None:
|
|
self.ws = ws
|
|
self.view = View()
|
|
self._frames: asyncio.Queue[dict[str, Any]] = asyncio.Queue()
|
|
self._reader: asyncio.Task[None] | None = None
|
|
self._closed = False
|
|
|
|
def start(self) -> None:
|
|
self._reader = asyncio.get_running_loop().create_task(self._read_loop())
|
|
|
|
async def _read_loop(self) -> None:
|
|
try:
|
|
while not self._closed:
|
|
raw = await self.ws.recv()
|
|
frame = json.loads(raw)
|
|
if frame.get("type") == "snap":
|
|
self.view.apply(frame)
|
|
self._frames.put_nowait(frame)
|
|
except (ConnectionClosed, asyncio.CancelledError):
|
|
pass
|
|
|
|
def send_model(self, model: dict[str, Any]) -> None:
|
|
"""Queue one JSON frame out (the task's `send_model`)."""
|
|
asyncio.get_running_loop().create_task(self.ws.send(json.dumps(model)))
|
|
|
|
async def send(self, model: dict[str, Any]) -> None:
|
|
await self.ws.send(json.dumps(model))
|
|
|
|
def raw(self, text: str) -> None:
|
|
"""One raw frame out (the malformed half: `not json`, …)."""
|
|
asyncio.get_running_loop().create_task(self.ws.send(text))
|
|
|
|
async def expect(self, type: str, wait_s: float = 10.0) -> dict[str, Any]:
|
|
"""The next frame of `type`; the frames before it are buffered and
|
|
re-queued (a later verb sees them, in order)."""
|
|
loop = asyncio.get_running_loop()
|
|
deadline = loop.time() + wait_s
|
|
held: list[dict[str, Any]] = []
|
|
while True:
|
|
remaining = deadline - loop.time()
|
|
if remaining <= 0:
|
|
msg = f"timed out waiting for a {type!r} frame"
|
|
raise TimeoutError(msg)
|
|
frame = await asyncio.wait_for(self._frames.get(), remaining)
|
|
if frame.get("type") == type:
|
|
for buffered in held:
|
|
self._frames.put_nowait(buffered)
|
|
return frame
|
|
held.append(frame)
|
|
|
|
async def collect(
|
|
self, type: str, n: int, wait_s: float = 30.0
|
|
) -> list[dict[str, Any]]:
|
|
"""`n` frames of `type` (in arrival order); the frames in between
|
|
are buffered and re-queued."""
|
|
loop = asyncio.get_running_loop()
|
|
deadline = loop.time() + wait_s
|
|
held: list[dict[str, Any]] = []
|
|
out: list[dict[str, Any]] = []
|
|
while len(out) < n:
|
|
remaining = deadline - loop.time()
|
|
if remaining <= 0:
|
|
msg = f"timed out after {len(out)}/{n} {type!r} frames"
|
|
raise TimeoutError(msg)
|
|
frame = await asyncio.wait_for(self._frames.get(), remaining)
|
|
if frame.get("type") == type:
|
|
out.append(frame)
|
|
else:
|
|
held.append(frame)
|
|
for buffered in held:
|
|
self._frames.put_nowait(buffered)
|
|
return out
|
|
|
|
async def close(self) -> None:
|
|
if self._closed:
|
|
return
|
|
self._closed = True
|
|
if self._reader is not None:
|
|
self._reader.cancel()
|
|
with contextlib.suppress(asyncio.CancelledError, Exception):
|
|
await self._reader
|
|
with contextlib.suppress(Exception):
|
|
await self.ws.close()
|
|
|
|
|
|
@pytest.fixture(scope="session")
|
|
def ws_client() -> Callable[..., Any]:
|
|
"""Factory: `client = await ws_client(url, code, token, name=…)`.
|
|
|
|
Connects to `/ws/{code}` on the named server, sends the `hello`
|
|
(the `token` joins or rejoins; absent, the server mints one and the
|
|
`welcome` echoes it) and returns the scripted `WsClient` (its reader
|
|
already running, its `view` merging snapshots).
|
|
"""
|
|
|
|
async def _connect(
|
|
url: str,
|
|
code: str,
|
|
token: str | None = None,
|
|
*,
|
|
name: str | None = None,
|
|
p: int = 1,
|
|
) -> WsClient:
|
|
ws = await websockets.connect(_uri(url, code), open_timeout=10.0)
|
|
hello: dict[str, Any] = {"type": "hello", "p": p}
|
|
if token is not None:
|
|
hello["token"] = token
|
|
if name is not None:
|
|
hello["name"] = name
|
|
await ws.send(json.dumps(hello))
|
|
client = WsClient(ws)
|
|
client.start()
|
|
return client
|
|
|
|
return _connect
|