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