**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`.
281 lines
10 KiB
Python
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")
|