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:
@@ -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
@@ -1,17 +1,25 @@
|
|||||||
"""Shared pytest fixtures.
|
"""Shared pytest fixtures + hermetic-DB bootstrap.
|
||||||
|
|
||||||
The auth integration tests hit the *real* Postgres database (per
|
The integration tests hit a real Postgres (Postgres-specific types: JSONB,
|
||||||
``DATABASE_URL``), so they persist data between runs. Without cleanup a
|
native ENUMs). To make ``cd backend && pytest`` work from a clean checkout
|
||||||
second run of the suite fails with 409 conflicts because the users it
|
with no manual setup, ``pytest_configure`` below brings up an ephemeral
|
||||||
registered on the first run already exist. The session-scoped fixture below
|
Postgres via docker compose and runs the migrations IF ``DATABASE_URL`` is
|
||||||
truncates all application tables once before the first test, making the suite
|
not already pointing at a reachable database. When it is (dev loop, or the
|
||||||
re-runnable.
|
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
|
If Docker isn't available and no DB is reachable, the whole DB-backed suite
|
||||||
keys, but the tables are listed explicitly for clarity.
|
is skipped with a clear message rather than erroring out 60+ times.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import os
|
||||||
|
import socket
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
from urllib.parse import urlsplit
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
@@ -24,17 +32,100 @@ from app.main import app
|
|||||||
|
|
||||||
TEST_TABLES = ("avatar_loadouts", "assets", "worlds", "sessions", "refresh_tokens", "users")
|
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)
|
@pytest.fixture(scope="session", autouse=True)
|
||||||
def _reset_database():
|
def _reset_database():
|
||||||
"""Truncate all application tables once per test session."""
|
"""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:
|
async def _truncate() -> None:
|
||||||
engine = create_async_engine(settings.DATABASE_URL)
|
engine = create_async_engine(settings.DATABASE_URL)
|
||||||
async with engine.begin() as conn:
|
async with engine.begin() as conn:
|
||||||
await conn.execute(
|
await conn.execute(text(f"TRUNCATE TABLE {', '.join(TEST_TABLES)} CASCADE"))
|
||||||
text(f"TRUNCATE TABLE {', '.join(TEST_TABLES)} CASCADE")
|
|
||||||
)
|
|
||||||
await engine.dispose()
|
await engine.dispose()
|
||||||
|
|
||||||
asyncio.run(_truncate())
|
asyncio.run(_truncate())
|
||||||
@@ -52,10 +143,6 @@ def _override_db():
|
|||||||
reusing one from the shared pool across requests raises "attached to a
|
reusing one from the shared pool across requests raises "attached to a
|
||||||
different loop" the moment a test makes more than one request. NullPool
|
different loop" the moment a test makes more than one request. NullPool
|
||||||
sidesteps it: every checkout is a brand-new connection.
|
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_engine = create_async_engine(settings.DATABASE_URL, poolclass=NullPool)
|
||||||
test_session_factory = async_sessionmaker(test_engine, expire_on_commit=False)
|
test_session_factory = async_sessionmaker(test_engine, expire_on_commit=False)
|
||||||
|
|||||||
+53
@@ -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.
|
||||||
@@ -166,6 +166,16 @@ def main():
|
|||||||
mp_env["GODOT_BIN"] = godot_bin
|
mp_env["GODOT_BIN"] = godot_bin
|
||||||
r = run(["bash", str(mp_script)], env=mp_env)
|
r = run(["bash", str(mp_script)], env=mp_env)
|
||||||
results["multiplayer 2p test"] = r.returncode == 0
|
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:
|
finally:
|
||||||
server_proc.terminate()
|
server_proc.terminate()
|
||||||
try:
|
try:
|
||||||
|
|||||||
Executable
+45
@@ -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 ==="
|
||||||
Reference in New Issue
Block a user