Files
undercooked/tests/e2e/test_us01_create_lobby.py
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

248 lines
9.9 KiB
Python

"""US-01 (create an anonymous lobby) e2e: a real browser creates a kitchen.
The landing screen's *Create lobby* is wired to `POST /api/rooms` (the
session token in the body — minted per tab, PLAN A16) and the
`/ws/{code}` handshake; the room's 5-character Crockford code is what
the screen shows. This suite asserts the create flow end to end —
keyboard-first (tab to the button, Enter), the code's alphabet, the
creator's seat row (the crown, the name), the *Start* button's
disabled-with-reason state, the process-capacity refusal (US-01
criterion 7: `503 room_full` from a `max_rooms=1` server turns into the
landing's "server is at capacity" message), and the landing's
before-the-wire validation (a short code is rejected without a
connection). The same-origin-only request rule (PLAN A12) is re-asserted
against the new module graph.
"""
from __future__ import annotations
import re
from typing import TYPE_CHECKING
import pytest
from playwright.sync_api import expect
from conftest import VIEWPORT, app_screen, load_app
if TYPE_CHECKING:
from playwright.sync_api import Browser, Page
pytestmark = pytest.mark.e2e
#: The Crockford base32 alphabet (server/codes.py): I, L, O and U are
#: excluded from a minted code.
CODE_PATTERN = re.compile(r"^[0-9A-HJKMNP-TV-Z]{5}$")
#: The token is `crypto.getRandomValues` 24 bytes → base64url (A16).
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 _new_page(browser: Browser, url: str) -> Page:
context = browser.new_context(viewport=VIEWPORT)
page = context.new_page()
page._owner_context = context # type: ignore[attr-defined]
return page
def _close(page: Page) -> None:
context = getattr(page, "_owner_context", None)
if context is not None:
context.close()
def test_create_lobby_via_keyboard_creates_a_room_and_shows_the_code(
browser: Browser, round_server, console_errors: list[str]
) -> None:
"""The door of US-01, keyboard-first: tab to *Create lobby*, Enter."""
url = round_server.url
page = _new_page(browser, url)
try:
load_app(page, url)
expect(page.get_by_role("heading", name="Kitchen lobby")).to_be_visible()
expect(page.get_by_role("button", name="Create lobby")).to_be_visible()
assert app_screen(page) == "landing"
page.get_by_role("button", name="Create lobby").focus()
page.keyboard.press("Enter")
# CONNECTING in between, LOBBY with the code on the other side.
expect(page.locator("#room-code")).to_be_visible(timeout=20000)
assert app_screen(page) == "lobby"
code = page.locator("#room-code").inner_text()
assert CODE_PATTERN.match(code), code
# The creator is seat 1 with the crown, named by the server's
# fallback ("Cook #1" — no name was given).
you = page.locator("#seat-list li[data-you]")
expect(you).to_contain_text("Cook #1")
expect(you.locator(".crown")).to_be_visible()
expect(you.locator(".chip")).to_be_visible()
# Start: enabled — the room pre-selects the campaign's first
# level and a solo host needs no ready-up (PLAN §6, §7).
assert page.locator("#start-btn").is_enabled()
assert page.locator("#start-reason").is_hidden()
# Identity: the per-tab token is in sessionStorage (A16), the code
# is remembered for the next visit.
token = page.evaluate("sessionStorage.getItem('uc.token')")
assert isinstance(token, str) and TOKEN_PATTERN.match(token), token
assert page.evaluate("sessionStorage.getItem('uc.lastCode')") == code
assert page.context.cookies() == [] # anonymous by design
assert console_errors == []
finally:
_close(page)
def test_create_lobby_with_a_display_name_shows_it_on_the_seat(
browser: Browser, round_server, console_errors: list[str]
) -> None:
"""The name is optional and non-authoritative (PLAN A16): the seam's
`createLobby({name})` puts it on the seat row, on both the creator's
and a joiner's page."""
url = round_server.url
page_a = _new_page(browser, url)
page_b = _new_page(browser, url)
try:
load_app(page_a, url)
load_app(page_b, url)
page_a.evaluate("() => window.__undercooked.createLobby({ name: 'Ada' })")
expect(page_a.locator("#room-code")).to_be_visible(timeout=20000)
code = page_a.locator("#room-code").inner_text()
page_b.locator("#join-code").press_sequentially(code)
page_b.locator("#join-form").get_by_role("button", name="Join").click()
expect(page_b.locator("#room-code")).to_be_visible(timeout=20000)
expect(page_a.locator("#seat-list li[data-you]")).to_contain_text("Ada")
expect(page_b.locator("#seat-list")).to_contain_text("Ada")
assert console_errors == []
finally:
_close(page_a)
_close(page_b)
def test_create_lobby_reports_a_full_server(
browser: Browser, full_server, console_errors: list[str]
) -> None:
"""US-01 criterion 7: a `max_rooms=1` process — the second create is a
`503 room_full` and the client turns it into the landing's capacity
message (the screen stays on the landing, the first room is untouched)."""
url = full_server.url
page = _new_page(browser, url)
try:
load_app(page, url)
page.get_by_role("button", name="Create lobby").click()
expect(page.locator("#room-code")).to_be_visible(timeout=20000)
first_code = page.locator("#room-code").inner_text()
page.evaluate("() => window.__undercooked.backToLanding()")
assert app_screen(page) == "landing"
page.get_by_role("button", name="Create lobby").click()
# The refusal lands on the landing screen (no CONNECTING escape).
expect(page.locator("#status")).to_contain_text("capacity", timeout=20000)
assert app_screen(page) == "landing"
# The first room is still live: rejoining its code works.
page.locator("#join-code").press_sequentially(first_code)
page.locator("#join-form").get_by_role("button", name="Join").click()
expect(page.locator("#room-code")).to_have_text(first_code, timeout=20000)
assert console_errors == []
finally:
_close(page)
def test_the_landing_rejects_a_short_code_before_connecting(
browser: Browser, round_server, console_errors: list[str]
) -> None:
"""Client-side validation: a 3-character code never reaches the server —
the screen stays on the landing and the field refuses a 6th character."""
url = round_server.url
page = _new_page(browser, url)
try:
load_app(page, url)
page.locator("#join-code").press_sequentially("abc")
page.locator("#join-form").get_by_role("button", name="Join").click()
expect(page.locator("#status")).to_have_text("A room code is 5 characters.")
assert app_screen(page) == "landing"
page.locator("#join-code").fill("")
page.locator("#join-code").press_sequentially("abcdef")
assert page.locator("#join-code").input_value() == "abcde"
assert console_errors == []
finally:
_close(page)
def test_the_landing_only_requests_its_own_origin(
browser: Browser, round_server, console_errors: list[str]
) -> None:
"""PLAN A12 against the task-04 module graph: the page, its two
stylesheets, `main.js` (the composition root) and `app.js`, the
identity/connection modules, the snapshot-buffer, event-queue,
render-loop, canvas-renderer, camera, fx, atlas and HUD modules,
the eight screen modules and nothing else — and every byte
same-origin."""
url = round_server.url
page = _new_page(browser, url)
requested: list[str] = []
try:
page.on("request", lambda request: requested.append(request.url))
load_app(page, url)
page.get_by_role("button", name="Create lobby").click()
expect(page.locator("#room-code")).to_be_visible(timeout=20000)
off_origin = [u for u in requested if not u.startswith(url)]
assert off_origin == [], off_origin
expected = {
f"{url}/",
f"{url}/static/css/base.css",
f"{url}/static/css/screens.css",
f"{url}/static/js/app.js",
f"{url}/static/js/identity.js",
f"{url}/static/js/input/keyboard.js",
f"{url}/static/js/main-flag.js",
f"{url}/static/js/main.js",
f"{url}/static/js/net/connection.js",
f"{url}/static/js/net/decode.js",
f"{url}/static/js/net/events.js",
f"{url}/static/js/net/snapshots.js",
f"{url}/static/js/render/atlas.js",
f"{url}/static/js/render/camera.js",
f"{url}/static/js/render/canvas.js",
f"{url}/static/js/render/fx.js",
f"{url}/static/js/render/loop.js",
f"{url}/static/js/screens/dom.js",
f"{url}/static/js/screens/landing.js",
f"{url}/static/js/screens/connecting.js",
f"{url}/static/js/screens/lobby.js",
f"{url}/static/js/screens/countdown.js",
f"{url}/static/js/screens/play.js",
f"{url}/static/js/screens/round_end.js",
f"{url}/static/js/screens/error.js",
f"{url}/static/js/shared/enums.js",
f"{url}/static/js/ui/help.js",
f"{url}/static/js/ui/hud.js",
f"{url}/api/rooms",
f"{url}/api/session",
f"{url}/api/levels",
}
assert set(requested) == expected, sorted(set(requested) ^ expected)
assert console_errors == []
finally:
_close(page)