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`.
175 lines
7.0 KiB
Python
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"
|