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:
@@ -0,0 +1 @@
|
||||
Playwright browser suites, one per user story (`test_usNN_*.py`, marker `e2e`).
|
||||
@@ -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()
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user