diff --git a/.dockerignore b/.dockerignore index 263bdbf9..c3de3202 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,3 +1,5 @@ +# Build context of the backend images (Dockerfile, Dockerfile.test). The frontend image +# builds from frontend/ with its own frontend/.dockerignore. __pycache__ *.pyc .venv diff --git a/CLAUDE.md b/CLAUDE.md index 7b198a9f..aac838a3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,6 +12,7 @@ make check-frontend # lint-frontend + typecheck-frontend + test-frontend (fron make test # uv run pytest (all tests) make test-unit # uv run pytest tests/unit/ (fast, no I/O) make test-integration # docker compose — backend + integration tests +make test-frontend-container # docker compose — production frontend image (nginx) + backend, proxy smoke tests make lint # ruff check + format check make format # auto-fix formatting and lint issues make typecheck # uv run mypy src/ @@ -38,6 +39,7 @@ Never run `make test`, `make test-unit`, `make test-integration`, or `make check ```bash make test-integration 2>&1 | tee /tmp/integration.log +make test-frontend-container 2>&1 | tee /tmp/frontend-container.log make check 2>&1 | tee /tmp/check.log ``` diff --git a/Makefile b/Makefile index 62813112..eeb72462 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: install lint format typecheck test test-unit test-integration check check-backend check-frontend setup-hooks messages compile-messages serve stop frontend up clean test-frontend lint-frontend typecheck-frontend live-inner-self +.PHONY: install lint format typecheck test test-unit test-integration check check-backend check-frontend setup-hooks messages compile-messages serve stop frontend up clean test-frontend lint-frontend typecheck-frontend live-inner-self test-frontend-container install: uv sync @@ -26,6 +26,12 @@ live-inner-self: test-integration: UID=$$(id -u) GID=$$(id -g) docker compose -f docker-compose.test.yml up --build --abort-on-container-exit --exit-code-from integration-tests +# Production frontend image (nginx) + backend, smoke-tested through the nginx proxy. +# Separate from test-integration so the integration job never builds the frontend image. +test-frontend-container: + UID=$$(id -u) GID=$$(id -g) docker compose -f docker-compose.test.yml --profile frontend up --build --abort-on-container-exit --exit-code-from frontend-smoke backend frontend frontend-smoke; \ + status=$$?; UID=$$(id -u) GID=$$(id -g) docker compose -f docker-compose.test.yml --profile frontend down; exit $$status + test-frontend: cd frontend && npx vitest run diff --git a/README.md b/README.md index bb2262c5..d8cc8649 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ Work in progress: expect rough edges. See [docs/VISION.md](docs/VISION.md) for w - Python 3.12+ and [uv](https://docs.astral.sh/uv/) - Node.js 20.19+ or 22.12+ (required by Vite 8) for the web UI -- Docker with Compose, only for the integration tests +- Docker with Compose, only for the integration tests and the containerized frontend - `lsof`: `make serve` and `make clean` use it to free ports ## Quick start @@ -71,6 +71,7 @@ make check-backend # ruff + mypy (strict) + pytest make check-frontend # eslint + tsc -b + vitest make test # pytest (unit tests) make test-integration # backend + integration tests in Docker Compose +make test-frontend-container # production frontend image + backend, smoke tests through the nginx proxy make format # auto-format and auto-fix Python code make setup-hooks # install pre-commit (format) and pre-push (scope-filtered check) git hooks make messages # extract translatable strings to .pot @@ -80,6 +81,17 @@ make clean # stop dev servers, wipe saves/ and logs/ Frontend type checking uses `tsc -b` so that the referenced projects (app, tests, Vite config) are all checked. `tsc --noEmit` against the root `frontend/tsconfig.json` checks nothing. `cd frontend && npm run build` runs the same type check before bundling. +### Containerized stack + +`docker-compose.test.yml` runs the backend (`Dockerfile`) and, behind the `frontend` profile, the production web UI (`frontend/Dockerfile`: a Node build stage, then nginx). nginx serves the built SPA, falls back to `index.html` for client-side routes, and proxies `/api` (including the game WebSocket) and `/health` to `$BACKEND_URL` (default `http://backend:8001`), the same routes the Vite dev server proxies. `make test-integration` does not build or start the frontend. `make test-frontend-container` builds both images and runs `tests/frontend_proxy/` against the nginx container: index, hashed assets, SPA fallback, `/health`, REST and a player WebSocket session, all through the proxy. To use the stack in a browser: + +```bash +docker compose -f docker-compose.test.yml --profile frontend up --build backend frontend +# web UI on http://localhost:8080 (FRONTEND_PORT overrides the port) +``` + +This stack uses the integration test content (`tests/integration/content`) and `DND_DICE_SEED=42`. + CI (GitHub Actions) runs the backend checks, the frontend checks and the integration tests. It skips the halves a change doesn't touch. ## Project layout diff --git a/docker-compose.test.yml b/docker-compose.test.yml index 743179a3..d2648df8 100644 --- a/docker-compose.test.yml +++ b/docker-compose.test.yml @@ -28,3 +28,37 @@ services: volumes: - ./saves:/app/saves entrypoint: ["uv", "run", "pytest", "tests/integration/", "-v"] + + # Production build of the web UI (nginx) proxying /api, the WebSocket and /health to + # backend. Behind the "frontend" profile so `make test-integration` never builds it; + # `make test-frontend-container` starts it together with frontend-smoke. + frontend: + profiles: ["frontend"] + build: ./frontend + environment: + BACKEND_URL: http://backend:8001 + ports: + - "${FRONTEND_PORT:-8080}:80" + depends_on: + backend: + condition: service_healthy + healthcheck: + test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1/"] + interval: 2s + timeout: 5s + retries: 10 + + frontend-smoke: + profiles: ["frontend"] + build: + context: . + dockerfile: Dockerfile.test + depends_on: + frontend: + condition: service_healthy + environment: + FRONTEND_URL: http://frontend + BACKEND_URL: http://backend:8001 + volumes: + - ./tests/frontend_proxy:/app/tests/frontend_proxy:ro + entrypoint: ["uv", "run", "pytest", "tests/frontend_proxy/", "-v", "-p", "no:cacheprovider"] diff --git a/frontend/.dockerignore b/frontend/.dockerignore new file mode 100644 index 00000000..34f795ea --- /dev/null +++ b/frontend/.dockerignore @@ -0,0 +1,10 @@ +node_modules +dist +dist-ssr +*.local +*.log +.vscode +.idea +.DS_Store +Dockerfile +.dockerignore diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 00000000..f4de10f0 --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,22 @@ +# Production build of the web UI, served by nginx. +# nginx proxies /api (REST + the game WebSocket) and /health to $BACKEND_URL, +# the same routes the Vite dev server proxies in vite.config.ts. + +FROM node:22-alpine AS build + +WORKDIR /app +COPY package.json package-lock.json ./ +RUN npm ci +COPY . . +RUN npm run build + +FROM nginx:1.27-alpine + +# The nginx image renders /etc/nginx/templates/*.template into /etc/nginx/conf.d/ +# with envsubst at start-up, substituting only variables defined in the environment. +ENV BACKEND_URL=http://backend:8001 +COPY nginx.conf.template /etc/nginx/templates/default.conf.template +COPY --from=build /app/dist /usr/share/nginx/html + +EXPOSE 80 +HEALTHCHECK --interval=2s --timeout=5s --retries=10 CMD wget -q -O /dev/null http://127.0.0.1/ || exit 1 diff --git a/frontend/nginx.conf.template b/frontend/nginx.conf.template new file mode 100644 index 00000000..107fb910 --- /dev/null +++ b/frontend/nginx.conf.template @@ -0,0 +1,48 @@ +# Rendered by the nginx image's envsubst step: only ${BACKEND_URL} is substituted, +# nginx's own $variables are left as is. + +map $http_upgrade $connection_upgrade { + default upgrade; + '' close; +} + +server { + listen 80; + server_name _; + + root /usr/share/nginx/html; + + # REST API and the game WebSocket (/api/ws/{session_id}). + location /api/ { + proxy_pass ${BACKEND_URL}; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + proxy_set_header Host $http_host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + # A player can sit on a turn for a long time; keep idle game sockets open. + proxy_read_timeout 1h; + proxy_send_timeout 1h; + client_max_body_size 10m; + } + + location = /health { + proxy_pass ${BACKEND_URL}; + proxy_set_header Host $http_host; + } + + # Hashed build output never changes under the same name. + location /assets/ { + expires 1y; + add_header Cache-Control "public, immutable"; + try_files $uri =404; + } + + # Client-side routing (BrowserRouter): unknown paths get the SPA shell. + location / { + add_header Cache-Control "no-cache"; + try_files $uri $uri/ /index.html; + } +} diff --git a/tests/frontend_proxy/test_frontend_proxy.py b/tests/frontend_proxy/test_frontend_proxy.py new file mode 100644 index 00000000..dfceb639 --- /dev/null +++ b/tests/frontend_proxy/test_frontend_proxy.py @@ -0,0 +1,130 @@ +"""Smoke tests for the production frontend container. + +Run by `make test-frontend-container` (the `frontend-smoke` compose service): nginx serves +the built SPA and must proxy /api (REST + the game WebSocket) and /health to the backend +exactly as the Vite dev server proxy does (frontend/vite.config.ts). +""" + +from __future__ import annotations + +import json +import os +import re +from collections.abc import Iterator +from http import HTTPStatus +from typing import Any + +import pytest +import requests +import websocket + +FRONTEND_URL = os.environ.get("FRONTEND_URL", "http://frontend") +BACKEND_URL = os.environ.get("BACKEND_URL", "http://backend:8001") +WS_BASE_URL = FRONTEND_URL.replace("http://", "ws://") + "/api/ws" + + +def _recv_json(ws: websocket.WebSocket) -> dict[str, Any]: + raw = ws.recv() + assert isinstance(raw, str) + msg = json.loads(raw) + assert isinstance(msg, dict) + return msg + + +def test_index_serves_built_spa() -> None: + resp = requests.get(f"{FRONTEND_URL}/", timeout=5) + assert resp.status_code == HTTPStatus.OK + assert resp.headers["content-type"].startswith("text/html") + assert '
' in resp.text + # The production build references hashed bundles, not the dev entry point. + assert "/src/main.tsx" not in resp.text + + +def test_built_assets_are_served() -> None: + index = requests.get(f"{FRONTEND_URL}/", timeout=5).text + scripts = re.findall(r']+src="(/assets/[^"]+\.js)"', index) + assert scripts, index + resp = requests.get(f"{FRONTEND_URL}{scripts[0]}", timeout=5) + assert resp.status_code == HTTPStatus.OK + assert "javascript" in resp.headers["content-type"] + + +def test_client_side_route_falls_back_to_index() -> None: + resp = requests.get(f"{FRONTEND_URL}/master", timeout=5) + assert resp.status_code == HTTPStatus.OK + assert '
' in resp.text + + +def test_missing_asset_is_404_not_index() -> None: + resp = requests.get(f"{FRONTEND_URL}/assets/does-not-exist.js", timeout=5) + assert resp.status_code == HTTPStatus.NOT_FOUND + + +def test_health_is_proxied_to_backend() -> None: + resp = requests.get(f"{FRONTEND_URL}/health", timeout=5) + assert resp.status_code == HTTPStatus.OK + assert resp.json()["status"] == "ok" + + +def test_api_is_proxied_to_backend() -> None: + via_frontend = requests.get(f"{FRONTEND_URL}/api/master/worlds", timeout=10) + direct = requests.get(f"{BACKEND_URL}/api/master/worlds", timeout=10) + assert via_frontend.status_code == HTTPStatus.OK + assert via_frontend.json() == direct.json() + + +def test_api_errors_pass_through() -> None: + resp = requests.get(f"{FRONTEND_URL}/api/master/sessions/no-such-session", timeout=5) + assert resp.status_code == HTTPStatus.NOT_FOUND + assert resp.headers["content-type"].startswith("application/json") + + +def test_websocket_upgrade_is_proxied() -> None: + ws = websocket.create_connection(f"{WS_BASE_URL}/no-such-session", timeout=10) + try: + msg = _recv_json(ws) + finally: + ws.close() + assert msg["type"] == "error" + assert "no-such-session" in msg["message"] + + +@pytest.fixture +def village_session() -> Iterator[tuple[str, str]]: + """A session created entirely through the frontend proxy. Yields (session_id, player_id).""" + resp = requests.post( + f"{FRONTEND_URL}/api/master/sessions", json={"world_name": "village", "lang": "en"}, timeout=10 + ) + resp.raise_for_status() + session_id = resp.json()["session_id"] + try: + resp = requests.post( + f"{FRONTEND_URL}/api/player/sessions/{session_id}/character", + json={ + "name": "Proxy Traveler", + "race": "human", + "char_class": "fighter", + "alignment": "true_neutral", + "start_location": "village_square", + "ability_scores": {"str": 12, "dex": 12, "con": 12, "int": 10, "wis": 10, "cha": 10}, + }, + timeout=10, + ) + resp.raise_for_status() + yield session_id, resp.json()["player_id"] + finally: + requests.delete(f"{FRONTEND_URL}/api/master/sessions/{session_id}", timeout=5) + + +def test_player_websocket_game_loop_through_proxy(village_session: tuple[str, str]) -> None: + session_id, player_id = village_session + ws = websocket.create_connection(f"{WS_BASE_URL}/{session_id}?player_id={player_id}", timeout=10) + try: + msg = _recv_json(ws) + while msg["type"] != "turn": + msg = _recv_json(ws) + ws.send(json.dumps({"type": "action", "name": "end_turn"})) + msg = _recv_json(ws) + assert msg["type"] != "error", msg + finally: + ws.close()