phase: 07_canvas_client_and_hud
**Final report — phase 07 final verification pass** - **Verified:** all 5 tasks present in `complete/`; app boots (`/healthz` 200, `/` 200, static + atlas + `/api/levels` served); `docs/CLIENT.md` module map matches the on-disk `app/static/` tree; no code defects found. - **Tests/lint/coverage:** `bash .agents/validate.sh` → ruff clean, pyright 0 errors, `undercooked-levels validate` pass, **full pytest 1838 passed, 97.21% coverage (floor 90%)**, slow sim stage 2 passed, repo hygiene clean. `uv run pytest tests/e2e -q` → **53 passed** (all 8 suites added this phase). - **Criteria:** (1) two tabs create→join→ready→start→serve one dish, keyboard only — green via `test_us01/02/03/04` ✔; (2) e2e green ✔; (3) canvas non-blank, pixels change after a move — `test_us04_render_smoke` ✔; (4) HUD score within 200 ms of the `served` event — asserted, green ✔; (5) zero console errors + zero off-origin requests — `test_atlas_loads` / `test_us01_landing_surface` ✔; (6) `docs/CLIENT.md` matches layout ✔; (7) 100% suite + 97.21% ✔; (8) docs pass below ✔; (9) no regression in completed phases (all prior suites green) ✔. - **README.md fixed:** status still said "next pending phase is 07" → now 07 complete, 08 pending; the phase-07 client text buried in the phase-05 bullet → its own "canvas client, art, HUD (phase 07)" bullet; stale "What does not exist yet" (claimed movement/controls missing) → rewritten to phases 08–14; "once the room flow ships" → "live in the browser today"; Layout `app/static/` "renderer arrives with the rest of phase 07" → full shipped-client map; Layout `docs/` "CLIENT lands with phase 07" → `CLIENT.md` listed; added `gen_art_index.py` Layout entry. - **AGENTS.md fixed:** "the eight screens" (7 listed) → "the seven screens" (+ `dom.js` is the shared builder, not a screen); e2e naming line → notes the non-story guard `test_atlas_loads.py`. - **Decisions:** no code changes were needed; only the two doc fixes above. Docs re-verified by `test_docs_commands`/`test_docs_index` (44 passed) plus the final full gate. - **Next pending phase:** `08_client_prediction_and_touch`.
This commit is contained in:
+219
-5
@@ -6,24 +6,55 @@ a fresh context and a fresh page, all function-scoped (see `browser` for why the
|
||||
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.
|
||||
|
||||
The phase-07 story suites (task 02) need servers a browser can drive end to end:
|
||||
`e2e_server` is a session-scoped factory that spins up a real `uvicorn.Server`
|
||||
on an ephemeral `127.0.0.1` port (never a routable interface) with a `Settings`
|
||||
resolved the way `tests/integration/conftest.py` resolves one (the authoritative
|
||||
`config.ENV_KEYS` contract, ambient `UNDERCOOKED_*` / `DEBUGPY*` stripped, the
|
||||
`levels_dir` override pointing at the campaign the factory names). The three
|
||||
session fixtures built on it:
|
||||
|
||||
* `round_server` — the `tests/fixtures/levels_round/` campaign (the shipped level 1,
|
||||
layout-verbatim, `target_score` 20 / `time_limit` 60 s — one served soup scores
|
||||
44-66, so the first serve wins the round; the forced-short-round the task-02
|
||||
result-screen test runs. The task's `time_limit: 4` idea is impossible: the
|
||||
validator's `TIME_LIMIT_INVALID` floor is `MIN_TIME_LIMIT_S` (30 s) and
|
||||
`create_app`'s content gate is unconditional — 60 s with a first-serve win is
|
||||
the shortest round a valid campaign can offer);
|
||||
* `small_server` — `max_players=2`, for the full-room story;
|
||||
* `full_server` — `max_rooms=1`, for the process-capacity "server full" story.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
import importlib
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
from collections.abc import Callable, Iterator
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import httpx
|
||||
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}
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
#: The playable campaign (phase 06 task 06's test-only copy of the shipped level 1).
|
||||
ROUND_LEVELS_DIR = REPO_ROOT / "tests" / "fixtures" / "levels_round"
|
||||
|
||||
config = importlib.import_module("undercooked.config")
|
||||
app_module = importlib.import_module("undercooked.server.app")
|
||||
uvicorn = importlib.import_module("uvicorn")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def browser() -> Iterator[Browser]:
|
||||
@@ -35,8 +66,8 @@ def browser() -> Iterator[Browser]:
|
||||
`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.
|
||||
(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)
|
||||
@@ -64,3 +95,186 @@ def page(context: BrowserContext) -> Iterator[Page]:
|
||||
yield opened
|
||||
finally:
|
||||
opened.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- live servers
|
||||
|
||||
|
||||
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 | 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 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")
|
||||
|
||||
|
||||
class E2EServer:
|
||||
"""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 _start_e2e_server(overrides: dict[str, object], levels_dir: Path) -> E2EServer:
|
||||
"""One live uvicorn server on an ephemeral loopback port.
|
||||
|
||||
The integration conftest's machine, verbatim in purpose: `Settings` from
|
||||
`**overrides` (field names, validated against `config.ENV_KEYS`) over the
|
||||
named `levels_dir` campaign, on an ephemeral `127.0.0.1` port (bound
|
||||
before the serving thread starts, so the port is known exactly — no log
|
||||
scraping, no collisions between concurrent runs).
|
||||
"""
|
||||
env = {
|
||||
key: value
|
||||
for key, value in os.environ.items()
|
||||
if not key.startswith(("UNDERCOOKED_", "DEBUGPY"))
|
||||
}
|
||||
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="127.0.0.1",
|
||||
port=0,
|
||||
log_level="warning",
|
||||
lifespan="on",
|
||||
)
|
||||
sock = uv_config.bind_socket()
|
||||
url = f"http://127.0.0.1:{sock.getsockname()[1]}"
|
||||
server = uvicorn.Server(uv_config)
|
||||
thread = threading.Thread(
|
||||
target=server.run, args=([sock],), name="undercooked-e2e", daemon=True
|
||||
)
|
||||
thread.start()
|
||||
_wait_for_health(url, thread)
|
||||
return E2EServer(url, application, server, thread)
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def e2e_server() -> Iterator[Callable[..., E2EServer]]:
|
||||
"""Factory: `e2e_server(ROUND_LEVELS_DIR, max_players=2)` — one call, one
|
||||
server. Each call starts a fresh `uvicorn.Server` on an ephemeral loopback
|
||||
port with a `Settings` resolved from the overrides (field names, validated
|
||||
against `config.ENV_KEYS`) over the named campaign. Shutdown is requested
|
||||
through `should_exit` so the lifespan stops cleanly; a session-end safety
|
||||
close covers a leaked server."""
|
||||
started: list[E2EServer] = []
|
||||
|
||||
def _make(levels_dir: Path, **overrides: object) -> E2EServer:
|
||||
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}")
|
||||
live = _start_e2e_server(dict(overrides), levels_dir)
|
||||
started.append(live)
|
||||
return live
|
||||
|
||||
def _close_all() -> None:
|
||||
for live in started:
|
||||
live.close()
|
||||
|
||||
_make.close_all = _close_all # type: ignore[attr-defined]
|
||||
yield _make
|
||||
_close_all() # type: ignore[attr-defined]
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def round_server(e2e_server: Callable[..., E2EServer]) -> Iterator[E2EServer]:
|
||||
"""The playable campaign (`tests/fixtures/levels_round/`): the forced-short
|
||||
round for the result-screen test (first serve wins — the module docstring)."""
|
||||
live = e2e_server(ROUND_LEVELS_DIR)
|
||||
try:
|
||||
yield live
|
||||
finally:
|
||||
live.close()
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def small_server(e2e_server: Callable[..., E2EServer]) -> Iterator[E2EServer]:
|
||||
"""`max_players=2` — the full-room story (US-02)."""
|
||||
live = e2e_server(ROUND_LEVELS_DIR, max_players=2)
|
||||
try:
|
||||
yield live
|
||||
finally:
|
||||
live.close()
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def full_server(e2e_server: Callable[..., E2EServer]) -> Iterator[E2EServer]:
|
||||
"""`max_rooms=1` — the process-capacity "server full" story (US-01)."""
|
||||
live = e2e_server(ROUND_LEVELS_DIR, max_rooms=1)
|
||||
try:
|
||||
yield live
|
||||
finally:
|
||||
live.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- page helpers
|
||||
|
||||
|
||||
def open_two_pages(
|
||||
browser: Browser, url: str
|
||||
) -> tuple[BrowserContext, Page, BrowserContext, Page]:
|
||||
"""Two tabs in two fresh contexts (the multiplayer harness, US-01/US-02).
|
||||
|
||||
Returns `(context_a, page_a, context_b, page_b)`; the caller closes the
|
||||
contexts in a `finally` (closing a context is the socket drop a story test
|
||||
wants when it "closes a laptop")."""
|
||||
context_a = browser.new_context(viewport=VIEWPORT)
|
||||
context_b = browser.new_context(viewport=VIEWPORT)
|
||||
page_a = context_a.new_page()
|
||||
page_b = context_b.new_page()
|
||||
return context_a, page_a, context_b, page_b
|
||||
|
||||
|
||||
def load_app(page: Page, url: str) -> None:
|
||||
"""Load the shell and wait for its module graph to settle."""
|
||||
page.goto(f"{url}/", wait_until="networkidle")
|
||||
|
||||
|
||||
def app_screen(page: Page) -> str | None:
|
||||
"""The screen the state machine is on (the `window.__undercooked` seam)."""
|
||||
return page.evaluate("window.__undercooked ? window.__undercooked.screen : null")
|
||||
|
||||
Reference in New Issue
Block a user