Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -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
Expand Down
2 changes: 2 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/
Expand All @@ -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
```

Expand Down
8 changes: 7 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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

Expand Down
14 changes: 13 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
34 changes: 34 additions & 0 deletions docker-compose.test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
10 changes: 10 additions & 0 deletions frontend/.dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
node_modules
dist
dist-ssr
*.local
*.log
.vscode
.idea
.DS_Store
Dockerfile
.dockerignore
22 changes: 22 additions & 0 deletions frontend/Dockerfile
Original file line number Diff line number Diff line change
@@ -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
48 changes: 48 additions & 0 deletions frontend/nginx.conf.template
Original file line number Diff line number Diff line change
@@ -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;
}
}
130 changes: 130 additions & 0 deletions tests/frontend_proxy/test_frontend_proxy.py
Original file line number Diff line number Diff line change
@@ -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 '<div id="root"></div>' 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'<script[^>]+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 '<div id="root"></div>' 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()
Loading