test(e2e): add Playwright tests for homepage, video page and load-more
This commit is contained in:
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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
|
||||
@@ -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}"
|
||||
Reference in New Issue
Block a user