PC3 Phase 3.1: hermetic backend tests + fresh-clone boot check + CI workflow

- backend/tests/conftest.py: pytest_configure brings up an ephemeral
  Postgres via docker compose + runs migrations when DATABASE_URL isn't
  already reachable, and tears it down after. `cd backend && pytest` now
  works from a clean checkout with zero manual setup (72 passed in ~28s);
  no docker and no DB -> the whole suite skips with a clear message instead
  of erroring 60+ times.
- scripts/verify_fresh_clone.sh: exports a pristine tree (git archive, so no
  .godot), imports from scratch, headless-boots the main menu + game scene,
  fails on any SCRIPT ERROR / failed autoload. Catches the stale-class-cache
  class of bug. PASS.
- run_checkpoint_tests.py runs both the 2p multiplayer test and the
  fresh-clone check.
- .forgejo/workflows/checkpoint.yml + docs/ci.md: the CI workflow (runs the
  checkpoint suite on push/PR) and how to enable it -- it needs a Forgejo
  Actions `host` runner registered on the AGP box, a one-time operator step.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015jtxAiu5dAW4WxCNdGkrkJ
This commit is contained in:
2026-09-05 22:11:53 +00:00
parent 50c6a31c25
commit 855311e731
5 changed files with 238 additions and 16 deletions
+27
View File
@@ -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
+103 -16
View File
@@ -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)
+53
View File
@@ -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 "<token>" \
--name agp-host --labels host
forgejo-runner daemon & # or a systemd unit
```
Install `forgejo-runner` from
<https://code.forgejo.org/forgejo/runner/releases>.
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/<PC>` on the dashboard runs before a
playtest build.
+10
View File
@@ -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:
+45
View File
@@ -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 ==="