| """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, |
| ) |
| |
| 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") |
|
|
| |
| 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") |
|
|
| |
| 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)") |
|
|
| |
| 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") |
| |
| |
| 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") |
|
|
| |
| member.evaluate("localStorage.clear()") |
| member.reload(wait_until="networkidle") |
|
|
| |
| 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")) |
|
|
| |
| 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, |
| ) |
|
|
| |
| 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 |
| |
| |
| 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()) |
|
|