"""US-01 (landing surface) e2e: a real browser loads the shell, cleanly. The shell is now the phase-07 screen machine (task 02 replaced phase 01's placeholder): loading the page runs the module graph without a single console error, the landing controls are reachable and 44 px, and — the point of PLAN A12 — loading, creating and joining makes requests to *nothing* but this process. The create/join flows are asserted here only as far as the landing surface reaches (the connecting screen, the error screen for a dead code, the validation); the lobby and round flows are the US-02/US-03 suites' territory. """ 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/js/screens/*.js by # hand: changing one must be a deliberate act. READY = "Pick a kitchen to start." 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 shell and wait for the module graph 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_reaches_the_lobby_and_a_dead_code_reaches_the_error_screen( page: Page, live_server: str, console_errors: list[str] ) -> None: """The landing surface now reaches the wire (phase 02): create shows the connecting screen and then the room's code; joining a code no room answers reaches the error screen with the normalised code — no more 'not implemented' placeholder.""" open_shell(page, live_server) page.get_by_role("button", name="Create lobby").click() # The connecting screen is transient (a fast server reaches the # lobby before this can assert it): wait for the room code either # way, and get back to the landing through the seam (or the # *Cancel* button, if the screen is still there). expect(page.locator("#room-code")).to_have_text( re.compile(r"[0-9A-HJKMNPQRSTVWXYZ]{5}"), timeout=20_000 ) cancel = page.get_by_role("button", name="Cancel") if cancel.count() > 0: cancel.click() else: page.evaluate("() => window.__undercooked.backToLanding()") expect(page.get_by_role("button", name="Create lobby")).to_be_visible(timeout=10_000) page.locator("#join-code").press_sequentially("abcde") page.get_by_role("button", name="Join").click() # The error screen names the refusal and the code it tried, normalised. expect(page.locator("#error-title")).to_have_text("That kitchen is gone") expect(page.locator("#error-code")).to_contain_text("Room code: 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. The census is the whole module graph (the two stylesheets, `main.js` — the composition root, — `app.js`, the identity, connection, snapshot-buffer, event-queue, render-loop, canvas-renderer, camera, fx, atlas and HUD modules, and the enum modules, the eight screen modules) plus the three API calls create and the lobby make — and nothing else.""" 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() expect(page.locator("#room-code")).to_be_visible(timeout=20_000) assert [url for url in requested if not url.startswith(live_server)] == [] # …and the check above is not vacuous: the page really did fetch its own assets. assert sorted({url for url in requested if url != f"{live_server}/"}) == [ f"{live_server}/api/levels", f"{live_server}/api/rooms", f"{live_server}/api/session", f"{live_server}/static/css/base.css", f"{live_server}/static/css/screens.css", f"{live_server}/static/js/app.js", f"{live_server}/static/js/identity.js", f"{live_server}/static/js/input/keyboard.js", f"{live_server}/static/js/main-flag.js", f"{live_server}/static/js/main.js", f"{live_server}/static/js/net/connection.js", f"{live_server}/static/js/net/decode.js", f"{live_server}/static/js/net/events.js", f"{live_server}/static/js/net/snapshots.js", f"{live_server}/static/js/render/atlas.js", f"{live_server}/static/js/render/camera.js", f"{live_server}/static/js/render/canvas.js", f"{live_server}/static/js/render/fx.js", f"{live_server}/static/js/render/loop.js", f"{live_server}/static/js/screens/connecting.js", f"{live_server}/static/js/screens/countdown.js", f"{live_server}/static/js/screens/dom.js", f"{live_server}/static/js/screens/error.js", f"{live_server}/static/js/screens/landing.js", f"{live_server}/static/js/screens/lobby.js", f"{live_server}/static/js/screens/play.js", f"{live_server}/static/js/screens/round_end.js", f"{live_server}/static/js/shared/enums.js", f"{live_server}/static/js/ui/help.js", f"{live_server}/static/js/ui/hud.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 = result["body"] assert body["status"] == "ok" assert body["version"] == __version__ assert isinstance(body["rooms"], int) and body["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() # K7XYZ is not a live room: the error screen is where a dead code lands. expect(page.locator("#error-title")).to_have_text( "That kitchen is gone", timeout=20_000 ) 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_is_absent_outside_the_play_screen(page: Page, live_server: str) -> None: """The canvas lives on the play screen (task 02); off it there is no `#kcanvas` at all, and on it the canvas mounts hidden and out of the a11y tree until task 04's renderer sizes and unhides it.""" open_shell(page, live_server) assert page.locator("#kcanvas").count() == 0 assert ( page.evaluate( """() => { const app = window.__undercooked; app.show('playing', {}); return document.querySelector('#kcanvas'); }""" ) is not None ) canvas = page.locator("#kcanvas") expect(canvas).to_be_hidden() assert canvas.get_attribute("aria-hidden") == "true"