Files
ducoterra 95ca3fd1fa 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`.
2026-10-10 18:03:52 -04:00

281 lines
10 KiB
Python

"""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.
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
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 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]:
"""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()
# ---------------------------------------------------------------- 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")