"""Parcours complet de l'interface dans un vrai navigateur (Playwright). Démarre son propre serveur sur une base vierge, joue le parcours d'un superadmin puis d'un membre, et dépose des captures dans /tmp/motoc-shots. Usage : .venv/bin/python scripts/ui_test.py """ from __future__ import annotations import os import shutil import signal import subprocess import sys import time from pathlib import Path ROOT = Path(__file__).resolve().parent.parent SHOTS = Path("/tmp/motoc-shots") DATA = Path("/tmp/motoc-ui") PORT = 7862 BASE = f"http://127.0.0.1:{PORT}" SUPER = "0612345678" MEMBER = "0698765432" errors: list[str] = [] step_no = 0 def shot(page, name: str) -> None: global step_no step_no += 1 page.screenshot(path=str(SHOTS / f"{step_no:02d}-{name}.png")) print(f" 📸 {step_no:02d}-{name}") def check(label: str, condition: bool, detail: str = "") -> None: if condition: print(f" \033[32m✓\033[0m {label}") else: errors.append(label) print(f" \033[31m✗\033[0m {label} {detail}") def start_server() -> subprocess.Popen: if DATA.exists(): shutil.rmtree(DATA) DATA.mkdir(parents=True) env = {**os.environ, "DATA_DIR": str(DATA), "SUPERADMIN_PHONE": SUPER} proc = subprocess.Popen( [ str(ROOT / ".venv/bin/uvicorn"), "app.main:app", "--host", "127.0.0.1", "--port", str(PORT), "--no-server-header", ], cwd=ROOT, env=env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, ) # Attend que le port réponde. import urllib.error import urllib.request for _ in range(50): try: urllib.request.urlopen(f"{BASE}/api/health", timeout=1) return proc except (urllib.error.URLError, OSError): time.sleep(0.2) raise RuntimeError("Le serveur n'a pas démarré.") def main() -> int: from playwright.sync_api import sync_playwright if SHOTS.exists(): shutil.rmtree(SHOTS) SHOTS.mkdir(parents=True) server = start_server() print(f"Serveur démarré sur {BASE}\n") try: with sync_playwright() as pw: browser = pw.chromium.launch() ctx = browser.new_context( viewport={"width": 390, "height": 844}, device_scale_factor=2, is_mobile=True, has_touch=True, locale="fr-FR", ) console_errors: list[str] = [] page = ctx.new_page() page.on( "console", lambda m: console_errors.append(m.text) if m.type == "error" else None, ) page.on("pageerror", lambda e: console_errors.append(str(e))) # --------------------------------------------------------------- print("\033[1m1. Onboarding du superadmin\033[0m") page.goto(BASE, wait_until="networkidle") check("écran de choix du profil affiché", page.is_visible("#step-role")) check("deux profils proposés", page.locator(".role-card").count() == 2) shot(page, "auth-profil-choix") page.click('.role-card[data-role="admin"]') page.wait_for_selector("#step-phone:not([hidden])", timeout=5000) check("rappel du profil admin", page.is_visible(".role-pill")) shot(page, "auth-numero") page.fill("#in-phone", SUPER) check("numéro formaté par groupes de deux", page.input_value("#in-phone") == "06 12 34 56 78") page.click("#btn-phone") page.wait_for_selector("#step-register:not([hidden])", timeout=5000) check("passage à la création de profil", True) page.fill("#in-first", "Sam") page.fill("#in-last", "Durand") page.fill("#in-nick", "Chef") page.fill("#in-pin1", "482913") page.fill("#in-pin2", "482913") shot(page, "auth-profil") page.click("#btn-register") page.wait_for_selector("#screen-groups.active", timeout=5000) check("connexion réussie", True) page.wait_for_timeout(700) check("WebSocket connecté", "En ligne" in page.inner_text("#conn-state")) check("bouton admin visible", page.is_visible("#btn-admin")) shot(page, "groupes-vide") # --------------------------------------------------------------- print("\n\033[1m2. Création des groupes\033[0m") page.click("#btn-admin") page.wait_for_selector("#screen-admin.active") page.wait_for_timeout(600) check("tableau de bord chargé", page.is_visible("#dash-kpis")) shot(page, "dashboard-vide") page.click('.tabs button[data-tab="tab-groups"]') page.fill("#ng-name", "Festival 2026") page.fill("#ng-desc", "Coordination générale") page.click("#btn-create-group") page.wait_for_timeout(600) page.fill("#ng-name", "Scène Cyclope") page.select_option("#ng-parent", label="Festival 2026") page.click("#btn-create-group") page.wait_for_timeout(600) page.fill("#ng-name", "Sécurité") page.select_option("#ng-parent", label="Festival 2026") page.click("#btn-create-group") page.wait_for_timeout(700) check("trois groupes créés", page.locator("#admin-groups .admin-row").count() == 3) shot(page, "admin-groupes") # --------------------------------------------------------------- print("\n\033[1m3. Enregistrement et affectation multiple\033[0m") page.click('.tabs button[data-tab="tab-users"]') page.fill("#nu-phones", f"{MEMBER}\n0611223344\n0755667788") page.click("#btn-add-users") page.wait_for_timeout(800) check("annuaire peuplé", page.locator("#admin-users .admin-row").count() >= 4) shot(page, "admin-membres") # Ouvre la fiche du membre et l'affecte à plusieurs groupes d'un coup. page.locator(f'#admin-users [data-user="{MEMBER}"]').click() page.wait_for_selector("#us-picker", timeout=5000) check("sélecteur multi-groupes présent", page.locator("#us-picker .pick-row").count() == 3) rows = page.locator("#us-picker .pick-row") rows.nth(0).click() rows.nth(1).click() rows.nth(2).click() shot(page, "admin-affectation-multiple") page.click("#us-assign") page.wait_for_timeout(900) check("affectation groupée effectuée", not page.is_visible("#us-picker")) page.click('[data-close-overlay="screen-admin"]') page.wait_for_timeout(500) # --------------------------------------------------------------- print("\n\033[1m4. Conversation\033[0m") page.wait_for_selector(".group-row", timeout=5000) check("arborescence des groupes affichée", page.locator(".group-row").count() == 3) check("sous-groupes indentés", page.locator(".group-row.child").count() == 2) shot(page, "groupes-liste") page.locator(".group-row").first.click() page.wait_for_selector("#screen-chat.active") page.wait_for_timeout(600) for text in [ "Briefing dans 10 minutes au PC sécurité ⚡", "@tous merci de confirmer votre présence", "Les badges sont à retirer à l'entrée artistes.", ]: page.fill("#in-message", text) page.click("#btn-send") page.wait_for_timeout(500) check("messages affichés", page.locator(".msg").count() >= 3) check("mention mise en évidence", page.locator(".msg-body .mention").count() >= 1) shot(page, "conversation") # Réponse à un message via l'appui long. page.locator(".msg").first.click(delay=700) page.wait_for_timeout(400) if page.is_visible("#sheet-backdrop.open"): check("feuille d'actions du message", True) shot(page, "message-actions") page.click('[data-act="reply"]') page.wait_for_timeout(300) check("barre de réponse active", page.is_visible("#reply-bar.show")) page.fill("#in-message", "Bien reçu, on arrive.") page.click("#btn-send") page.wait_for_timeout(600) check("réponse citée", page.locator(".reply-quote").count() >= 1) shot(page, "conversation-reponse") else: check("feuille d'actions du message", False, "(appui long sans effet)") # Fiche du groupe page.click("#btn-group-info") page.wait_for_timeout(700) check("fiche du groupe ouverte", page.is_visible("#seg-notif")) shot(page, "groupe-infos") page.click("#sheet-backdrop", position={"x": 195, "y": 40}) page.wait_for_timeout(400) # --------------------------------------------------------------- print("\n\033[1m5. Tableau de bord alimenté\033[0m") page.click("[data-close-chat]") page.wait_for_timeout(500) page.click("#btn-admin") page.wait_for_timeout(1000) check("KPI rendus", page.locator("#dash-kpis .kpi").count() == 12) check("histogramme journalier", page.locator("#chart-daily svg .bar").count() >= 1) check("histogramme horaire", page.locator("#chart-hourly svg").count() == 1) check("classement des groupes", page.locator("#chart-groups .rank-row").count() >= 1) check("classement des membres", page.locator("#chart-members .rank-row").count() >= 1) check("journal d'audit alimenté", page.locator("#audit-list .audit-row").count() >= 1) shot(page, "dashboard-complet") page.click('.tabs button[data-tab="tab-stats"]') page.wait_for_timeout(700) check("jauge de stockage", page.is_visible("#storage-bar")) shot(page, "admin-systeme") page.click('[data-close-overlay="screen-admin"]') page.wait_for_timeout(400) # --------------------------------------------------------------- print("\n\033[1m6. Réglages\033[0m") page.click("#btn-settings") page.wait_for_selector("#screen-settings.active") page.wait_for_timeout(400) check("profil pré-rempli", page.input_value("#set-first") == "Sam") check("numéro en lecture seule", page.is_disabled("#set-phone")) shot(page, "reglages") page.click('[data-close-overlay="screen-settings"]') page.wait_for_timeout(400) # --------------------------------------------------------------- print("\n\033[1m7. Compte verrouillé et demande d'aide\033[0m") # Contexte séparé : sinon le membre hérite du localStorage (et donc # de la session) du superadmin. member_ctx = browser.new_context( viewport={"width": 390, "height": 844}, device_scale_factor=2, is_mobile=True, has_touch=True, locale="fr-FR", ) member = member_ctx.new_page() member.goto(BASE, wait_until="networkidle") member.click('.role-card[data-role="benevole"]') member.fill("#in-phone", MEMBER) member.click("#btn-phone") member.wait_for_selector("#step-register:not([hidden])", timeout=5000) member.fill("#in-first", "Léa") member.fill("#in-last", "Martin") member.fill("#in-pin1", "739154") member.fill("#in-pin2", "739154") member.click("#btn-register") member.wait_for_selector("#screen-groups.active", timeout=5000) member.wait_for_timeout(800) check("le membre voit ses 3 groupes", member.locator(".group-row").count() == 3) member.screenshot(path=str(SHOTS / "20-membre-groupes.png")) print(" 📸 20-membre-groupes") # Déconnexion puis échecs de PIN jusqu'au verrouillage. member.evaluate("localStorage.clear()") member.reload(wait_until="networkidle") # Un bénévole ne doit pas pouvoir entrer par la porte « Admin ». member.click('.role-card[data-role="admin"]') member.fill("#in-phone", MEMBER) member.click("#btn-phone") member.wait_for_selector("#step-login:not([hidden])", timeout=5000) member.fill("#in-pin", "739154") member.click("#btn-login") member.wait_for_timeout(700) check( "accès admin refusé à un bénévole", "administrateur" in member.inner_text("#auth-error").lower(), member.inner_text("#auth-error"), ) member.screenshot(path=str(SHOTS / "21-acces-admin-refuse.png")) print(" 📸 21-acces-admin-refuse") member.click("#step-login [data-back-to-role]") member.wait_for_selector("#step-role:not([hidden])", timeout=5000) member.click('.role-card[data-role="benevole"]') member.fill("#in-phone", MEMBER) member.click("#btn-phone") member.wait_for_selector("#step-login:not([hidden])", timeout=5000) for _ in range(5): member.fill("#in-pin", "000001") member.click("#btn-login") member.wait_for_timeout(350) check("message de verrouillage", "tentatives" in member.inner_text("#auth-error").lower()) member.screenshot(path=str(SHOTS / "21-compte-verrouille.png")) print(" 📸 21-compte-verrouille") member.click("[data-open-help]") member.wait_for_timeout(300) check("formulaire d'assistance accessible", member.is_visible("#step-help")) member.fill("#in-help-msg", "Je me suis trompé de code plusieurs fois, désolé !") member.screenshot(path=str(SHOTS / "22-demande-aide.png")) print(" 📸 22-demande-aide") member.click("#btn-help-send") member.wait_for_timeout(900) check("retour à l'écran numéro après envoi", member.is_visible("#step-phone")) # L'admin doit voir la demande arriver en direct. page.bring_to_front() page.wait_for_timeout(1200) check( "demande visible par l'admin", page.locator("#help-requests-inline .help-item").count() == 1, ) shot(page, "admin-demande-aide") page.locator('#help-requests-inline [data-act="unlock"]').click() page.wait_for_timeout(900) check( "demande traitée", page.locator("#help-requests-inline .help-item").count() == 0, ) # Le membre peut se reconnecter. member.bring_to_front() member.fill("#in-phone", MEMBER) member.click("#btn-phone") member.wait_for_selector("#step-login:not([hidden])", timeout=5000) member.fill("#in-pin", "739154") member.click("#btn-login") member.wait_for_selector("#screen-groups.active", timeout=5000) check("reconnexion après déblocage", True) member.screenshot(path=str(SHOTS / "24-membre-reconnecte.png")) print(" 📸 24-membre-reconnecte") # --------------------------------------------------------------- print("\n\033[1m8. Temps réel entre deux clients\033[0m") member.locator(".group-row").first.click() member.wait_for_selector("#screen-chat.active") member.wait_for_timeout(600) member.fill("#in-message", "Bien reçu chef, je suis en route.") member.click("#btn-send") member.wait_for_timeout(400) member.screenshot(path=str(SHOTS / "25-membre-conversation.png")) print(" 📸 25-membre-conversation") page.bring_to_front() page.locator(".group-row").first.click() page.wait_for_selector("#screen-chat.active") page.wait_for_timeout(900) check( "message du membre reçu en direct", "en route" in page.inner_text("#thread"), ) shot(page, "conversation-deux-clients") # --------------------------------------------------------------- real_errors = [ e for e in console_errors # Le service worker et le push ne fonctionnent qu'en HTTPS : # leurs erreurs en http://127.0.0.1 sont attendues. if not any(x in e.lower() for x in ("service worker", "push", "sw.js", "manifest")) ] print("\n\033[1m9. Console navigateur\033[0m") check("aucune erreur JavaScript", not real_errors, str(real_errors[:3])) browser.close() finally: server.send_signal(signal.SIGTERM) server.wait(timeout=10) print(f"\n\033[1m{'ÉCHECS : ' + str(errors) if errors else 'Tout est passé.'}\033[0m") print(f"Captures : {SHOTS}\n") return 1 if errors else 0 if __name__ == "__main__": sys.exit(main())