test(e2e): add Playwright tests for homepage, video page and load-more

This commit is contained in:
2026-07-26 20:22:20 +04:00
parent 54a4368ea8
commit 606175d718
4 changed files with 418 additions and 0 deletions
+160
View File
@@ -0,0 +1,160 @@
"""
Fixtures pytest pour les tests E2E : serveur PHP local + page Playwright.
Le serveur de développement PHP (`php -S`) est démarré une fois par session
de test sur 127.0.0.1:8000 (ou sur un port libre si 8000 est déjà pris).
Les appels aux API distantes (PeerTube, Castopod) passent par le vrai réseau :
le premier chargement remplit le cache du site (cache/api), ce qui peut
prendre un certain temps.
Lancement : /tmp/test-venv/bin/python -m pytest tests/e2e/
"""
import os
import re
import socket
import subprocess
import tempfile
import time
import urllib.request
from pathlib import Path
import pytest
from playwright.sync_api import sync_playwright
PROJECT_ROOT = Path(__file__).resolve().parents[2]
SERVER_HOST = "127.0.0.1"
DEFAULT_PORT = 8000
STARTUP_TIMEOUT = 30 # secondes pour que php -S accepte des connexions
WARMUP_TIMEOUT = 90 # premier hit : remplit les caches API (Castopod/PeerTube)
PAGE_TIMEOUT_MS = 60000
def _port_libre(port):
"""Retourne True si aucun service n'écoute sur le port donné."""
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
return s.connect_ex((SERVER_HOST, port)) != 0
def _trouver_port():
"""Utilise le port 8000 si possible, sinon un port libre quelconque."""
if _port_libre(DEFAULT_PORT):
return DEFAULT_PORT
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.bind((SERVER_HOST, 0))
return s.getsockname()[1]
def lire_constante_config(nom, defaut=None):
"""
Lit une constante define('NOM', 'valeur') dans la configuration PHP.
Cherche dans config.default.php puis dans config.local.php (qui a le
dernier mot, comme dans includes/config.php). Ne gère que les valeurs
chaînes simples — suffisant pour SITE_NAME, PEERTUBE_URL, etc.
"""
valeur = defaut
for fichier in ("config.default.php", "config.local.php"):
chemin = PROJECT_ROOT / "includes" / fichier
if not chemin.is_file():
continue
texte = chemin.read_text(encoding="utf-8")
motif = r"define\(\s*'" + re.escape(nom) + r"'\s*,\s*'((?:[^'\\]|\\.)*)'\s*\)"
m = re.search(motif, texte)
if m:
valeur = m.group(1).replace("\\'", "'").replace("\\\\", "\\")
return valeur
@pytest.fixture(scope="session")
def base_url():
"""Démarre `php -S` à la racine du projet et fournit l'URL de base."""
port = _trouver_port()
url = f"http://{SERVER_HOST}:{port}"
log = tempfile.NamedTemporaryFile(
mode="w+", prefix="php-e2e-", suffix=".log", delete=False
)
# PHP_CLI_SERVER_WORKERS : le serveur intégré est mono-thread par défaut,
# ce qui bloque quand le navigateur demande plusieurs PHP en parallèle.
env = dict(os.environ, PHP_CLI_SERVER_WORKERS="4")
proc = subprocess.Popen(
["php", "-S", f"{SERVER_HOST}:{port}", "-t", str(PROJECT_ROOT)],
cwd=str(PROJECT_ROOT),
stdout=log,
stderr=subprocess.STDOUT,
env=env,
)
# Attendre que le serveur accepte des connexions
deadline = time.monotonic() + STARTUP_TIMEOUT
pret = False
while time.monotonic() < deadline:
if proc.poll() is not None:
log.seek(0)
raise RuntimeError(
"Le serveur PHP s'est arrêté au démarrage :\n" + log.read()
)
try:
with socket.create_connection((SERVER_HOST, port), timeout=1):
pret = True
break
except OSError:
time.sleep(0.2)
if not pret:
proc.terminate()
raise RuntimeError(
f"Le serveur PHP ne répond pas après {STARTUP_TIMEOUT} s"
)
# Pré-chauffe les caches (initCategories + appels API distants)
try:
with urllib.request.urlopen(url + "/index.php", timeout=WARMUP_TIMEOUT) as rep:
if rep.status != 200:
raise RuntimeError(f"index.php a répondu HTTP {rep.status}")
except Exception:
proc.terminate()
log.seek(0)
raise RuntimeError(
"Impossible de charger la page d'accueil :\n" + log.read()
)
yield url
proc.terminate()
try:
proc.wait(timeout=10)
except subprocess.TimeoutExpired:
proc.kill()
log.close()
@pytest.fixture(scope="session")
def navigateur():
"""Instance Chromium partagée pour toute la session de test."""
with sync_playwright() as p:
browser = p.chromium.launch()
yield browser
browser.close()
@pytest.fixture()
def page(navigateur):
"""Nouvelle page vierge pour chaque test (contexte isolé)."""
context = navigateur.new_context()
context.set_default_timeout(PAGE_TIMEOUT_MS)
pg = context.new_page()
yield pg
context.close()
@pytest.fixture(scope="session")
def site_name():
"""Valeur de la constante SITE_NAME de la configuration du site."""
return lire_constante_config("SITE_NAME")
@pytest.fixture(scope="session")
def peertube_url():
"""Valeur de la constante PEERTUBE_URL de la configuration du site."""
return lire_constante_config("PEERTUBE_URL")
+62
View File
@@ -0,0 +1,62 @@
"""Tests E2E de la page d'accueil (index.php)."""
import re
import pytest
def test_homepage_title(page, base_url, site_name):
"""Le titre de la page correspond au SITE_NAME configuré."""
page.goto(base_url + "/index.php", wait_until="domcontentloaded")
assert page.title() == site_name
def test_homepage_csrf_meta_token(page, base_url):
"""La balise meta csrf-token est présente au format 'timestamp:hmac'."""
page.goto(base_url + "/index.php", wait_until="domcontentloaded")
token = page.locator('meta[name="csrf-token"]').get_attribute("content")
assert token, "La balise meta csrf-token est absente ou vide"
assert re.fullmatch(r"\d+:[0-9a-f]{64}", token), (
f"Format de token CSRF inattendu : {token!r}"
)
def test_homepage_main_sections(page, base_url):
"""Les sections principales sont rendues côté serveur."""
page.goto(base_url + "/index.php", wait_until="domcontentloaded")
# Titres de sections vidéo (identifiants stables du markup)
assert page.locator("#shorts-heading").inner_text() == "Shorts"
assert page.locator("#recent-videos-heading").inner_text() == "Dernières vidéos"
assert page.locator("#trending-videos-heading").inner_text() == "Tendances"
# Section du fil d'actualités Mastodon
assert page.locator(".mastodon-section .mt-title").inner_text() != ""
# Au moins une grille de vidéos
assert page.locator(".video-section").count() >= 3
def test_homepage_view_more_buttons(page, base_url):
"""Chaque section vidéo propose un bouton 'Voir plus'."""
page.goto(base_url + "/index.php", wait_until="domcontentloaded")
boutons = page.locator("button.view-more")
# Sections 'Dernières vidéos' et 'Tendances' toujours présentes
# (+ une par catégorie contenant des vidéos)
assert boutons.count() >= 2
for i in range(boutons.count()):
# text_content : le texte DOM brut (inner_text subit text-transform: uppercase)
assert boutons.nth(i).text_content() == "Voir plus"
def test_homepage_video_cards(page, base_url):
"""Des cartes vidéo remontent de l'API PeerTube (nécessite le réseau)."""
page.goto(base_url + "/index.php", wait_until="domcontentloaded")
cartes = page.locator(".video-card")
if cartes.count() == 0:
pytest.skip("Aucune vidéo reçue de l'API PeerTube (réseau indisponible ?)")
# Chaque carte expose un identifiant vidéo exploitable
for i in range(cartes.count()):
assert cartes.nth(i).get_attribute("data-video-id")
+106
View File
@@ -0,0 +1,106 @@
"""Tests E2E de l'endpoint AJAX ajax/load-more-videos.php.
L'endpoint exige trois protections cumulées (dans l'ordre) :
1. l'en-tête X-Requested-With: XMLHttpRequest ;
2. une origine (Origin, ou Referer à défaut) strictement identique au site ;
3. un token CSRF stateless valide (champ POST csrf_token).
"""
import pytest
ENDPOINT = "/ajax/load-more-videos.php"
@pytest.fixture()
def csrf_token(page, base_url):
"""Récupère un token CSRF frais depuis la balise meta de l'accueil."""
page.goto(base_url + "/index.php", wait_until="domcontentloaded")
token = page.locator('meta[name="csrf-token"]').get_attribute("content")
assert token, "La balise meta csrf-token est absente de l'accueil"
return token
def _post(page, base_url, query="", token=None, ajax=True, origin=None):
"""POST sur l'endpoint avec les protections activables une à une."""
headers = {}
if ajax:
headers["X-Requested-With"] = "XMLHttpRequest"
if origin is not None:
headers["Origin"] = origin
form = {}
if token is not None:
form["csrf_token"] = token
return page.request.post(
base_url + ENDPOINT + query, form=form, headers=headers
)
def test_load_more_rejects_non_ajax(page, base_url, csrf_token):
"""Sans X-Requested-With, la requête est refusée (403)."""
rep = _post(page, base_url, "?type=recent&page=1",
token=csrf_token, ajax=False, origin=base_url)
assert rep.status == 403
assert rep.json() == {"error": "Accès non autorisé"}
def test_load_more_rejects_bad_origin(page, base_url, csrf_token):
"""Une origine étrangère est refusée (403)."""
rep = _post(page, base_url, "?type=recent&page=1",
token=csrf_token, origin="https://example.com")
assert rep.status == 403
assert rep.json() == {"error": "Origine non autorisée"}
def test_load_more_rejects_bad_csrf(page, base_url):
"""Un token CSRF forgé est refusé (403)."""
rep = _post(page, base_url, "?type=recent&page=1",
token="1234567890:tokenforge", origin=base_url)
assert rep.status == 403
assert rep.json() == {"error": "Token CSRF invalide"}
def test_load_more_rejects_invalid_type(page, base_url, csrf_token):
"""Un type de vidéos inconnu est refusé (400)."""
rep = _post(page, base_url, "?type=inconnu&page=1",
token=csrf_token, origin=base_url)
assert rep.status == 400
assert rep.json() == {"error": "Type de vidéos non valide"}
def test_load_more_category_requires_id(page, base_url, csrf_token):
"""Le type 'category' sans ID de catégorie est refusé (400)."""
rep = _post(page, base_url, "?type=category&page=1",
token=csrf_token, origin=base_url)
assert rep.status == 400
assert rep.json() == {"error": "ID de catégorie manquant ou invalide"}
def test_load_more_recent_structure(page, base_url, csrf_token):
"""Une requête valide renvoie la structure JSON attendue."""
rep = _post(page, base_url, "?type=recent&page=1",
token=csrf_token, origin=base_url)
assert rep.status == 200
payload = rep.json()
assert payload["success"] is True
assert payload["page"] == 1
assert isinstance(payload["hasMore"], bool)
assert isinstance(payload["html"], str)
def test_load_more_recent_returns_videos(page, base_url, csrf_token):
"""Le HTML retourné contient des cartes vidéo (nécessite le réseau)."""
rep = _post(page, base_url, "?type=recent&page=1",
token=csrf_token, origin=base_url)
assert rep.status == 200
payload = rep.json()
if not payload["html"]:
pytest.skip("Aucune vidéo reçue de l'API PeerTube (réseau indisponible ?)")
assert 'class="video-card"' in payload["html"]
assert "data-video-id=" in payload["html"]
# L'endpoint n'a plus rien à servir bien au-delà du catalogue
rep_fin = _post(page, base_url, "?type=recent&page=10000",
token=csrf_token, origin=base_url)
assert rep_fin.status == 200
assert rep_fin.json()["hasMore"] is False
+90
View File
@@ -0,0 +1,90 @@
"""Tests E2E de la page vidéo (video.php)."""
import json
import pytest
def _premier_video_id(page, base_url):
"""Récupère l'identifiant de la première carte vidéo de l'accueil."""
page.goto(base_url + "/index.php", wait_until="domcontentloaded")
ids = page.locator(".video-card").evaluate_all(
"(els) => els.map(e => e.dataset.videoId).filter(Boolean)"
)
# Déduplique en conservant l'ordre (la même vidéo peut être en short et en grille)
uniques = list(dict.fromkeys(ids))
if not uniques:
pytest.skip("Aucune vidéo reçue de l'API PeerTube (réseau indisponible ?)")
return uniques[0]
def _jsonld_blocks(page):
"""Parse tous les blocs <script type='application/ld+json'> de la page."""
blocs = []
for brut in page.locator('script[type="application/ld+json"]').all_text_contents():
blocs.append(json.loads(brut))
return blocs
def test_video_page_jsonld_videoobject(page, base_url):
"""La page vidéo expose des données structurées VideoObject valides."""
video_id = _premier_video_id(page, base_url)
page.goto(base_url + f"/video.php?id={video_id}", wait_until="domcontentloaded")
blocs = _jsonld_blocks(page)
types = [b.get("@type") for b in blocs]
assert "VideoObject" in types, f"Pas de VideoObject dans les JSON-LD : {types}"
video_ld = blocs[types.index("VideoObject")]
assert video_ld.get("@context") == "https://schema.org"
assert video_ld.get("name"), "Le nom du VideoObject est vide"
assert video_ld.get("embedUrl", "").endswith(f"/videos/embed/{video_id}")
assert video_ld.get("thumbnailUrl"), "thumbnailUrl manquant"
assert video_ld.get("uploadDate"), "uploadDate manquant"
assert video_ld.get("duration"), "duration manquante"
# Le fil d'Ariane est également publié
assert "BreadcrumbList" in types
def test_video_page_peertube_embed(page, base_url, peertube_url):
"""Le lecteur embarque l'iframe PeerTube de la vidéo demandée."""
video_id = _premier_video_id(page, base_url)
page.goto(base_url + f"/video.php?id={video_id}", wait_until="domcontentloaded")
iframe = page.locator(".video-player iframe")
assert iframe.count() == 1, "L'iframe du lecteur est absente"
src = iframe.get_attribute("src")
assert src.startswith(f"{peertube_url}/videos/embed/{video_id}"), (
f"Source d'iframe inattendue : {src}"
)
def test_video_page_suggestions(page, base_url):
"""La colonne de suggestions propose d'autres vidéos du site."""
video_id = _premier_video_id(page, base_url)
page.goto(base_url + f"/video.php?id={video_id}", wait_until="domcontentloaded")
suggestions = page.locator(".video-suggestions")
assert suggestions.count() == 1, "La section de suggestions est absente"
assert suggestions.locator("h2").inner_text() == "Vidéos suggérées"
cartes = suggestions.locator(".suggested-video")
if cartes.count() == 0:
pytest.skip("Aucune vidéo suggérée (API PeerTube indisponible ?)")
hrefs = cartes.locator("a.suggested-video-link").evaluate_all(
"(els) => els.map(e => e.getAttribute('href'))"
)
for href in hrefs:
assert href and href.startswith("video.php?id="), (
f"Lien de suggestion inattendu : {href}"
)
# La vidéo courante ne doit pas se suggérer elle-même
assert f"video.php?id={video_id}" not in hrefs
def test_video_page_invalid_id_redirects(page, base_url):
"""Un identifiant vidéo invalide redirige vers l'accueil (sans appel réseau)."""
page.goto(base_url + "/video.php?id=pas-un-uuid-valide",
wait_until="domcontentloaded")
assert page.url.endswith("/index.php"), f"URL après redirection : {page.url}"