**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`.
248 lines
10 KiB
Python
248 lines
10 KiB
Python
"""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"
|