Files
undercooked/tests/e2e/test_us01_landing_surface.py
T
ducoterra f51ba919af phase: 05_anonymous_lobbies_and_room_lifecycle
All green. Final report:

**Phase 05 final verification pass — complete, 100% green.**

**Verified (all 5 tasks already in `complete/`):**
- `RoomSupervisor` import, `POST /api/rooms` → 5-char Crockford code (`VBE8Y`), `GET /api/rooms/{code}` → seat list, join/session/404 flows — all live over HTTP
- `docs/ROOMS.md` present with FSM diagram, crash-isolation and "What is lost on restart" sections
- Phase tests all pass: lifecycle 4-seat `WON`→`LOBBY` (7 tests), crash isolation, GC/idle via `FakeClock` (no sleeps), FSM, tick cadence, supervisor, sessions

**Results:**
- `uv run pytest` → **1506 passed, 0 failed**; coverage **98.89%** (>90% floor; `room.py` 99%, `rooms.py` 97%, `sessions.py` 99% — all ≥95% per-module gate)
- `uv run ruff check .` → clean; `uv run pyright` → 0 errors; `uv run undercooked-levels validate` → content OK; server boots, `/healthz` 200, client at `/` 200
- e2e US-01 suite re-run after fixes: 10 passed

**Documentation — inconsistencies found and fixed:**
- `README.md`: status now lists phases 01–05 complete, next pending `06_realtime_websocket_protocol`; dropped "(phase 05, in progress)"; 3 stale "Not implemented until phase 05." references corrected
- `app/static/js/app.js` + `tests/e2e/test_us01_landing_surface.py`: placeholder string updated in lockstep to "Not implemented until phase 07." (phase 07 task 02 wires the buttons), comments updated to reflect phase-05 completion
- `AGENTS.md`: reviewed in full — §6 already documents all phase-05 modules accurately; no changes needed

**Decisions:** no code behavior changes beyond the user-visible placeholder string; no live-infrastructure touch (only a local loopback server, stopped).

**Next pending phase:** `06_realtime_websocket_protocol`.
2026-10-08 05:17:21 -04:00

175 lines
7.0 KiB
Python

"""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 07."
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"