diff --git a/.forgejo/workflows/checkpoint.yml b/.forgejo/workflows/checkpoint.yml new file mode 100644 index 0000000..2809738 --- /dev/null +++ b/.forgejo/workflows/checkpoint.yml @@ -0,0 +1,27 @@ +name: checkpoint + +# Runs the full checkpoint suite (migrations, backend pytest, Godot smoke +# tests, multiplayer, fresh-clone boot) on every push and PR. +# +# Requires a Forgejo Actions runner with: +# - Docker access (the suite starts an ephemeral Postgres via docker compose) +# - the Godot 4.6.2 headless binary at $HOME/godot/godot (or `godot` on PATH) +# - the backend Python deps installed (backend/requirements.txt) +# A `host`-type runner on the AGP box already has all of this. See docs/ci.md. + +on: + push: + branches: [main] + pull_request: + +jobs: + checkpoint: + runs-on: host + steps: + - uses: actions/checkout@v4 + + - name: Backend deps + run: python -m pip install -r backend/requirements.txt + + - name: Checkpoint suite + run: python scripts/run_checkpoint_tests.py --postgres-port 25690 --backend-port 18690 diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 2f02edf..7822425 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -1,17 +1,25 @@ -"""Shared pytest fixtures. +"""Shared pytest fixtures + hermetic-DB bootstrap. -The auth integration tests hit the *real* Postgres database (per -``DATABASE_URL``), so they persist data between runs. Without cleanup a -second run of the suite fails with 409 conflicts because the users it -registered on the first run already exist. The session-scoped fixture below -truncates all application tables once before the first test, making the suite -re-runnable. +The integration tests hit a real Postgres (Postgres-specific types: JSONB, +native ENUMs). To make ``cd backend && pytest`` work from a clean checkout +with no manual setup, ``pytest_configure`` below brings up an ephemeral +Postgres via docker compose and runs the migrations IF ``DATABASE_URL`` is +not already pointing at a reachable database. When it is (dev loop, or the +checkpoint runner which stands the DB up itself), that path is skipped and +the existing database is reused. -Truncation order doesn't strictly matter because ``CASCADE`` follows foreign -keys, but the tables are listed explicitly for clarity. +If Docker isn't available and no DB is reachable, the whole DB-backed suite +is skipped with a clear message rather than erroring out 60+ times. """ import asyncio +import os +import socket +import subprocess +import sys +import time +from pathlib import Path +from urllib.parse import urlsplit import pytest from sqlalchemy import text @@ -24,17 +32,100 @@ from app.main import app TEST_TABLES = ("avatar_loadouts", "assets", "worlds", "sessions", "refresh_tokens", "users") +BACKEND_DIR = Path(__file__).resolve().parents[1] +_COMPOSE_PROJECT = "agp-pytest" +_EPHEMERAL_PG_PORT = 25432 + +# Set by _ensure_database() when it starts a container it owns; torn down in +# pytest_unconfigure. +_started_container = False +_db_unavailable_reason: str | None = None + + +def _port_open(host: str, port: int, timeout: float = 1.0) -> bool: + try: + with socket.create_connection((host, port), timeout=timeout): + return True + except OSError: + return False + + +def _db_reachable(url: str) -> bool: + parts = urlsplit(url) + return _port_open(parts.hostname or "localhost", parts.port or 5432) + + +def _run(cmd: list[str], **kw) -> subprocess.CompletedProcess: + return subprocess.run(cmd, cwd=BACKEND_DIR, capture_output=True, text=True, **kw) + + +def pytest_configure(config): + global _started_container, _db_unavailable_reason + + if _db_reachable(settings.DATABASE_URL): + return # dev loop / checkpoint runner already provided one + + if not _run(["docker", "--version"]).returncode == 0: + _db_unavailable_reason = ( + "no reachable DATABASE_URL and docker is unavailable -- run the " + "checkpoint suite (scripts/run_checkpoint_tests.py) or start Postgres " + "manually" + ) + return + + env = os.environ.copy() + env["COMPOSE_PROJECT_NAME"] = _COMPOSE_PROJECT + env["POSTGRES_HOST_PORT"] = str(_EPHEMERAL_PG_PORT) + env["POSTGRES_PORT"] = str(_EPHEMERAL_PG_PORT) + up = _run(["docker", "compose", "up", "-d", "postgres"], env=env) + if up.returncode != 0: + _db_unavailable_reason = f"docker compose up postgres failed:\n{up.stderr[-800:]}" + return + _started_container = True + + for _ in range(30): + r = _run( + ["docker", "compose", "exec", "-T", "postgres", + "pg_isready", "-U", "agp_user", "-d", "agp_db"], + env=env, + ) + if r.returncode == 0: + break + time.sleep(1) + else: + _db_unavailable_reason = "ephemeral Postgres never became ready" + return + + url = f"postgresql+asyncpg://agp_user:agp_password@localhost:{_EPHEMERAL_PG_PORT}/agp_db" + os.environ["DATABASE_URL"] = url + settings.DATABASE_URL = url + + mig = _run([sys.executable, "-m", "alembic", "upgrade", "head"], + env={**os.environ, "DATABASE_URL": url}) + if mig.returncode != 0: + _db_unavailable_reason = f"alembic upgrade head failed:\n{mig.stderr[-800:]}" + + +def pytest_unconfigure(config): + if not _started_container: + return + env = os.environ.copy() + env["COMPOSE_PROJECT_NAME"] = _COMPOSE_PROJECT + env["POSTGRES_HOST_PORT"] = str(_EPHEMERAL_PG_PORT) + env["POSTGRES_PORT"] = str(_EPHEMERAL_PG_PORT) + _run(["docker", "compose", "down", "-v"], env=env) + @pytest.fixture(scope="session", autouse=True) def _reset_database(): """Truncate all application tables once per test session.""" + if _db_unavailable_reason: + pytest.skip(_db_unavailable_reason, allow_module_level=True) async def _truncate() -> None: engine = create_async_engine(settings.DATABASE_URL) async with engine.begin() as conn: - await conn.execute( - text(f"TRUNCATE TABLE {', '.join(TEST_TABLES)} CASCADE") - ) + await conn.execute(text(f"TRUNCATE TABLE {', '.join(TEST_TABLES)} CASCADE")) await engine.dispose() asyncio.run(_truncate()) @@ -52,10 +143,6 @@ def _override_db(): reusing one from the shared pool across requests raises "attached to a different loop" the moment a test makes more than one request. NullPool sidesteps it: every checkout is a brand-new connection. - - This lived (identically) in three separate test files before it was - lifted here; any file still defining its own local `_override_db` - simply shadows this one with the same behaviour. """ test_engine = create_async_engine(settings.DATABASE_URL, poolclass=NullPool) test_session_factory = async_sessionmaker(test_engine, expire_on_commit=False) diff --git a/docs/ci.md b/docs/ci.md new file mode 100644 index 0000000..0706e23 --- /dev/null +++ b/docs/ci.md @@ -0,0 +1,53 @@ +# CI + +`.forgejo/workflows/checkpoint.yml` runs the full checkpoint suite +(`scripts/run_checkpoint_tests.py`: migrations on a fresh DB, backend +`pytest`, all Godot smoke tests, the 2-player multiplayer test, and the +fresh-clone boot check) on every push to `main` and every PR. + +**It does not run yet** — it needs a Forgejo Actions runner, which is a +one-time operator setup on the AGP host. + +## Enabling it + +1. **Turn on Actions in Forgejo** — in the `forgejo` container's + `app.ini`, add: + + ```ini + [actions] + ENABLED = true + ``` + + then restart the container. + +2. **Register a `host` runner.** The suite starts Docker containers and runs + the Godot binary, so a container runner would need docker-in-docker + a + Godot image. A `host`-type runner (runs jobs directly on the AGP box, + where Docker, `~/godot/godot`, and Python already live) is simplest: + + ```bash + # on the AGP host + FORGEJO_URL=http://localhost:3000 + # get a token: Forgejo → Site Admin → Actions → Runners → Create + forgejo-runner register --no-interactive \ + --instance "$FORGEJO_URL" --token "" \ + --name agp-host --labels host + forgejo-runner daemon & # or a systemd unit + ``` + + Install `forgejo-runner` from + . + +3. Push a commit — the `checkpoint` workflow should appear under the repo's + **Actions** tab. + +## Interim: run it by hand + +Until the runner is up, the same gate is: + +```bash +python scripts/run_checkpoint_tests.py +``` + +which is also what `/checkpoint/` on the dashboard runs before a +playtest build. diff --git a/scripts/run_checkpoint_tests.py b/scripts/run_checkpoint_tests.py index a8d1ca6..6040a09 100755 --- a/scripts/run_checkpoint_tests.py +++ b/scripts/run_checkpoint_tests.py @@ -166,6 +166,16 @@ def main(): mp_env["GODOT_BIN"] = godot_bin r = run(["bash", str(mp_script)], env=mp_env) results["multiplayer 2p test"] = r.returncode == 0 + + # Fresh-clone boot: a from-scratch tree must import + boot with + # zero script errors (no reliance on a committed .godot cache). + fc_script = ROOT / "scripts" / "verify_fresh_clone.sh" + if fc_script.exists(): + print("\n--- scripts/verify_fresh_clone.sh ---") + fc_env = os.environ.copy() + fc_env["GODOT_BIN"] = godot_bin + r = run(["bash", str(fc_script)], env=fc_env) + results["fresh-clone boot"] = r.returncode == 0 finally: server_proc.terminate() try: diff --git a/scripts/verify_fresh_clone.sh b/scripts/verify_fresh_clone.sh new file mode 100755 index 0000000..f82a9d8 --- /dev/null +++ b/scripts/verify_fresh_clone.sh @@ -0,0 +1,45 @@ +#!/usr/bin/env bash +# Prove the game boots cleanly from a bare checkout -- no committed .godot +# cache, no prior editor scan. Catches the class-of-bug where cross-file +# `class_name` references only resolve because a stale cache happened to list +# the old classes (which is exactly how the avatar system broke pre-PC3). +# +# Usage: scripts/verify_fresh_clone.sh +# Exit 0 iff a from-scratch import + headless boot of the main menu produces +# zero SCRIPT ERROR / failed-autoload lines. +set -uo pipefail +cd "$(dirname "$0")/.." + +GODOT_BIN="${GODOT_BIN:-godot}" +command -v "$GODOT_BIN" >/dev/null 2>&1 || GODOT_BIN="$HOME/godot/godot" +command -v "$GODOT_BIN" >/dev/null 2>&1 || { echo "godot binary not found"; exit 1; } + +WORK="$(mktemp -d)" +trap 'rm -rf "$WORK"' EXIT + +echo "=== exporting a pristine tree (tracked files only, no .godot) ===" +git archive --format=tar HEAD | tar -x -C "$WORK" +[ -f "$WORK/project.godot" ] || { echo "export produced no project.godot"; exit 1; } +[ -d "$WORK/.godot" ] && { echo "FAIL: .godot/ is tracked in git -- it must not be"; exit 1; } + +LOG="$WORK/_boot.log" + +echo "=== import pass (builds the class cache from scratch) ===" +"$GODOT_BIN" --headless --path "$WORK" --import >"$LOG" 2>&1 || true + +echo "=== headless boot: main menu ===" +"$GODOT_BIN" --headless --path "$WORK" scenes/main_menu.tscn --quit-after 150 >>"$LOG" 2>&1 || true + +echo "=== headless boot: game scene ===" +"$GODOT_BIN" --headless --path "$WORK" scenes/game.tscn --quit-after 90 >>"$LOG" 2>&1 || true + +BAD=$(grep -nE 'SCRIPT ERROR|Failed to instantiate an autoload|does not inherit from|Failed to load script' "$LOG" || true) +if [ -n "$BAD" ]; then + echo + echo "FAIL: fresh clone does not boot clean --" + echo "$BAD" + echo "=== SMOKE RESULT: FAIL ===" + exit 1 +fi + +echo "=== SMOKE RESULT: PASS ==="