"""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)