Ajoute "Publier" : exporte un jeu en exécutable Windows autonome
Fonctionnalité mise de côté depuis le tout début de ce chantier
("jouable en toute autonomie"). Nouveau bouton "📦 Publier" dans la barre
de navigation du jeu (après "▶️ Jouer") : construit un zip contenant un
Python portable + Flask embarqués, une copie figée du moteur de rendu
(screens/db/filters + le strict minimum de core/) et des données du jeu
(game.db + uploads/), lancé en double-cliquant sur run.bat — aucune
installation requise, 100% hors-ligne (voir le commit précédent qui a
auto-hébergé polices/animate.css, dernière dépendance CDN de l'app).
Exploration préalable a confirmé que screens/ et db/ sont déjà
totalement découplés de auth/routes/core (seul lien : un try/except
optionnel dans db/connection.py) et que templates/play.html n'appelle
que 4 endpoints "purs" (aucune logique d'auth mélangée dedans) — ce qui
a permis de réutiliser ces routes quasiment telles quelles dans un
mini-serveur Flask séparé plutôt que de les réécrire.
- db/constants.py : PROJECTS_DIR devient surchargeable via
FORGE_PROJECTS_DIR (même schéma que auth/connection.py) — le serveur
joueur autonome pointe ainsi vers son propre dossier "projects/"
embarqué.
- publish/vendor_runtime.py : télécharge (une fois par poste, mis en
cache sous data/publish_vendor/ — déjà ignoré par git) le ZIP Python
embeddable officiel (python.org) et vendore Flask via pip --target ;
fonctions séparées et mockables pour ne jamais déclencher de vrai
téléchargement dans les tests.
- publish/player_app_template.py : mini-Flask autonome, réutilise
core/flask_app.py et core/jinja_filters.py tels quels (SLUG figé en
dur au moment de la publication, csrf_token() factice puisqu'aucune
session n'existe dans cet export). sys.path doit être complété
manuellement au démarrage : le python311._pth de la distribution
embeddable ne référence que le dossier de python.exe lui-même, jamais
celui du script lancé.
- publish/build_package.py : assemble le zip dans un dossier temporaire
(jamais les vrais fichiers de l'app), copié/nettoyé après envoi de la
réponse HTTP (routes/publish/publish_game.py, déjà protégée par la
garde d'accès existante — aucune vérification supplémentaire).
- templates/base.html : bouton + modale (avancement séquentiel, jamais
de suivi serveur réel — le build est rapide) qui déclenche le
téléchargement du zip via un blob, comme la modale des codes de
récupération déjà en place (posée À L'INTÉRIEUR de <main> pour que
pjax.js la remplace et rejoue son script à chaque navigation).
Vérifié pour de vrai (pas seulement via les tests) : zip construit,
extrait, lancé avec le Python embeddable réel — /, /game/test/play,
/game/test/runtime-payload et /static/style.css répondent tous 200.
tests/test_publish.py (nouveau, vendor mocké) : structure du zip,
SLUG correctement substitué, route protégée par la même isolation par
projet que le reste de l'app. 203 tests au total.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
5d6747770c
commit
1289079da5
@@ -0,0 +1,91 @@
|
||||
"""Publier un jeu en exécutable Windows autonome (bouton "Publier", voir
|
||||
routes/publish/publish_game.py et publish/build_package.py). Le vrai
|
||||
téléchargement du Python portable + Flask (publish/vendor_runtime.py)
|
||||
n'a JAMAIS lieu ici : `ensure_vendor_ready` est monkeypatché vers un
|
||||
dossier factice minuscule — ces tests vérifient l'ASSEMBLAGE du zip
|
||||
(fichiers présents, structure), jamais un vrai lancement du serveur
|
||||
embarqué (nécessite un environnement Windows réel, voir le plan)."""
|
||||
import os
|
||||
import zipfile
|
||||
|
||||
import pytest
|
||||
|
||||
import publish.build_package as build_package
|
||||
|
||||
from tests.test_auth import anon_client # noqa: F401
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_vendor(tmp_path, monkeypatch):
|
||||
"""Un python-embed/pylibs factice, juste assez pour que l'assemblage
|
||||
du zip (copie de fichiers) fonctionne sans jamais toucher au réseau."""
|
||||
python_dir = tmp_path / "python-embed-amd64"
|
||||
python_dir.mkdir()
|
||||
(python_dir / "python.exe").write_bytes(b"fake-exe")
|
||||
pylibs_dir = tmp_path / "pylibs"
|
||||
pylibs_dir.mkdir()
|
||||
(pylibs_dir / "flask").mkdir()
|
||||
(pylibs_dir / "flask" / "__init__.py").write_text("# fake", encoding="utf-8")
|
||||
|
||||
def _fake_ensure_vendor_ready():
|
||||
return str(python_dir), str(pylibs_dir)
|
||||
|
||||
monkeypatch.setattr(build_package.vendor_runtime, "ensure_vendor_ready", _fake_ensure_vendor_ready)
|
||||
return python_dir, pylibs_dir
|
||||
|
||||
|
||||
def test_build_game_zip_contains_the_expected_files(fake_vendor, game):
|
||||
zip_path, game_name = build_package.build_game_zip(game)
|
||||
try:
|
||||
assert game_name # nom du jeu (pytest_test_game par défaut, voir conftest.py)
|
||||
with zipfile.ZipFile(zip_path) as zf:
|
||||
names = set(zf.namelist())
|
||||
for expected in [
|
||||
"run.bat", "player_app.py",
|
||||
f"projects/{game}/game.db",
|
||||
"screens/__init__.py", "db/__init__.py", "filters/__init__.py",
|
||||
"core/__init__.py", "core/flask_app.py", "core/jinja_filters.py",
|
||||
"templates/play.html",
|
||||
"static/style.css", "static/csrf_fetch.js",
|
||||
"python-embed/python.exe", "python-embed/pylibs/flask/__init__.py",
|
||||
]:
|
||||
assert expected in names, f"{expected} manquant du zip"
|
||||
finally:
|
||||
if os.path.isfile(zip_path):
|
||||
os.remove(zip_path)
|
||||
|
||||
|
||||
def test_build_game_zip_bakes_the_slug_into_player_app(fake_vendor, game):
|
||||
zip_path, _ = build_package.build_game_zip(game)
|
||||
try:
|
||||
with zipfile.ZipFile(zip_path) as zf:
|
||||
player_app_src = zf.read("player_app.py").decode("utf-8")
|
||||
assert f'SLUG = "{game}"' in player_app_src
|
||||
assert "_SLUG_PLACEHOLDER" not in player_app_src
|
||||
finally:
|
||||
if os.path.isfile(zip_path):
|
||||
os.remove(zip_path)
|
||||
|
||||
|
||||
def test_publish_route_returns_a_zip_attachment(fake_vendor, client, game):
|
||||
resp = client.post(f"/game/{game}/publish")
|
||||
assert resp.status_code == 200
|
||||
assert resp.headers["Content-Type"] == "application/zip"
|
||||
assert "attachment" in resp.headers["Content-Disposition"]
|
||||
assert resp.data[:2] == b"PK" # signature d'un fichier zip
|
||||
|
||||
|
||||
def test_publish_route_is_isolated_like_other_game_routes(fake_vendor, anon_client):
|
||||
"""Réutilise la garde d'accès existante (core/auth_guard.py) sans
|
||||
code supplémentaire — un compte non-admin ne doit pas pouvoir publier
|
||||
le projet d'un autre (même vérification que /game/<slug> lui-même,
|
||||
voir tests/test_auth.py::test_non_admin_user_is_isolated_to_their_own_project)."""
|
||||
from tests.test_auth import _register, _confirm_2fa, _cleanup_project
|
||||
|
||||
_register(anon_client, "publishisolation@example.com")
|
||||
_confirm_2fa(anon_client)
|
||||
try:
|
||||
resp = anon_client.post("/game/un-projet-qui-nest-pas-le-sien/publish")
|
||||
assert resp.status_code == 403
|
||||
finally:
|
||||
_cleanup_project("publishisolation@example.com")
|
||||
Reference in New Issue
Block a user