**Phase 01 final verification pass — all criteria green**
- Verified (nothing left to implement; all 4 task files already in `complete/`): toolchain lock, typed `Settings`+`.env.example`, debugpy bootstrap, app factory, CLI (`--host/--port/--reload/--levels-dir`, `python -m undercooked`), static shell + `/static`, security headers, no-CDN/doc guards, `validate.sh`.
- `uv sync --extra dev` → exit 0; `uv.lock` present (untracked → harness commits it with the phase).
- `uv run undercooked --host 127.0.0.1 --port 8000` → `curl /healthz` = 200 `{"status":"ok","version":"0.1.0","rooms":0}` (superset of the criterion's body — now documented in README); `curl /` = 200 shell HTML; static css/js 200; CSP/nosniff/no-referrer/DENY on 200, 404 and 405 too.
- `DEBUGPY=1`: port 5678 is held on this machine by the user's own podman dev-container debugpy adapter — **no live change made/killed**; verified via `DEBUGPY=1 DEBUGPY_PORT=5679` → listener opens, `/healthz` still 200; the occupied-port case degraded gracefully as designed (warning, server serves). Bad config → one line + exit 2 ✓.
- `bash .agents/validate.sh` (root **and** `.agents/`) → exit 0: `ruff check .` 0 errors, `pyright` 0 errors, levels-CLI notice-skip (matches README prose), **284 passed** full suite incl. 10 Playwright e2e, coverage **100.00%** (floor 90), repo hygiene clean. `uv run pytest -m e2e` → 10 passed.
- Docs fixed: README Status → phase 01 complete + next pending 02 (was pointing at `todo/01…`), documented real `/healthz` body, precedence now "CLI flags > env > .env > default", added the PLAN §13-promised **How to play** section (honest about shell-only today), Layout rows marked what exists vs phases 02/07. AGENTS.md: recorded the `data:`-URL exemption in the no-CDN rule (test allows it) and matched the session-token rule to the browser's WebCrypto/base64url implementation. Also corrected a stale "session-wide chromium" docstring in `tests/e2e/conftest.py` (fixtures are function-scoped).
- Deviations: no container build run (needs an image pull; not a phase criterion — `Containerfile`/`compose.yaml` reviewed and consistent with `docs/DEPLOYMENT.md`); `HEAD /` is 405 (routes are GET-only by design; health checks use GET). No git index/history commands run.
- Next pending phase: `02_level_content_and_validation`.
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 05."
|
|
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"
|