phase: 01_runnable_service_scaffold

**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`.
This commit is contained in:
2026-10-06 01:51:20 -04:00
parent f84c6ebaef
commit 6fd83446ec
43 changed files with 5478 additions and 30 deletions
+1
View File
@@ -0,0 +1 @@
Playwright browser suites, one per user story (`test_usNN_*.py`, marker `e2e`).
+66
View File
@@ -0,0 +1,66 @@
"""Playwright fixtures for the browser suites in `tests/e2e/`.
The project depends on the `playwright` package, not on `pytest-playwright`, so
the three fixtures the plugin would provide are defined here — a headless chromium,
a fresh context and a fresh page, all function-scoped (see `browser` for why the scope
is load-bearing). A fresh context per test matters too: `sessionStorage` (the anonymous
`uc.token`, `uc.lastCode`) is scoped to it, so tests cannot observe each other's
identity.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
import pytest
from playwright.sync_api import sync_playwright
if TYPE_CHECKING:
from collections.abc import Iterator
from playwright.sync_api import Browser, BrowserContext, Page, ViewportSize
# The default desktop viewport; story tests that care about small screens call
# `page.set_viewport_size` before navigating.
VIEWPORT: ViewportSize = {"width": 1280, "height": 720}
@pytest.fixture
def browser() -> Iterator[Browser]:
"""A headless chromium, live for exactly one test.
Function scope is load-bearing, not shyness about startup time. The
*synchronous* Playwright API drives the browser from an asyncio event loop it
owns in the main thread, and that loop stays flagged as running for as long as
`sync_playwright()` is open — so a session- or module-scoped fixture makes every
later `async def` test in the run die with
`RuntimeError: Runner.run() cannot be called from a running event loop`
(pytest-asyncio, `asyncio_mode = "auto"`). Closing the whole stack inside the
test leaves the main thread clean for whatever runs next.
"""
with sync_playwright() as playwright:
launched = playwright.chromium.launch(headless=True)
try:
yield launched
finally:
launched.close()
@pytest.fixture
def context(browser: Browser) -> Iterator[BrowserContext]:
"""A brand-new browser context per test: isolated storage and cookies."""
created = browser.new_context(viewport=VIEWPORT)
try:
yield created
finally:
created.close()
@pytest.fixture
def page(context: BrowserContext) -> Iterator[Page]:
"""A tab inside `context`, closed after the test."""
opened = context.new_page()
try:
yield opened
finally:
opened.close()
+174
View File
@@ -0,0 +1,174 @@
"""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"