# ----------------------------------------------------------------------------- # Immo-Ka — Agrégateur de maisons à vendre (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # scripts/valider_rendu.py : validation automatisée du rendu d'affichage. # # Matrice largeurs × pages (Playwright/Chromium) : pour chaque combinaison — # · AUCUN débordement horizontal (scrollWidth <= largeur de la fenêtre) # · aucune image cassée parmi les images chargées (naturalWidth > 0) # · cibles tactiles : liens/boutons visibles < 40 px de haut signalés # · erreurs console JavaScript # · capture d'écran pleine page (régression visuelle : comparer les runs) # # Usage : python3 scripts/valider_rendu.py [BASE_URL] [DOSSIER_SORTIE] # Par défaut : http://localhost:18096 (tunnel SSH vers M4M64a:8096) et # docs/screenshots/validation-/ . Code retour 1 si un échec bloquant. # ----------------------------------------------------------------------------- from __future__ import annotations import json import sys import time import urllib.request from datetime import date from pathlib import Path from playwright.sync_api import sync_playwright BASE = sys.argv[1] if len(sys.argv) > 1 else "http://localhost:18096" OUT = Path(sys.argv[2] if len(sys.argv) > 2 else f"docs/screenshots/validation-{date.today().isoformat()}") # matrice de largeurs : smartphones (portrait), paysage mobile, tablette, desktop VIEWPORTS = [ ("mobile-360", 360, 780, True), ("mobile-390", 390, 844, True), ("mobile-414", 414, 896, True), ("paysage-844", 844, 390, True), ("tablette-768", 768, 1024, True), ("desktop-1024", 1024, 768, False), ("desktop-1440", 1440, 900, False), ] def _api(path: str): with urllib.request.urlopen(BASE + path, timeout=30) as r: return json.loads(r.read().decode()) def pick_cases() -> list[tuple[str, str]]: """Pages à tester — routes fixes + cas limites réels choisis dans l'API.""" pages: list[tuple[str, str]] = [ ("accueil", "/"), ("recherche-carte", "/?view=carte"), ("recherche-sans-resultat", "/?q=zzzz-introuvable-zzz"), ("agences", "/agences"), ("stats", "/stats"), ("contact", "/contact"), ] try: ls = _api("/api/listings?limit=500")["listings"] by_imgs = sorted(ls, key=lambda l: len(l.get("images") or [])) cases = { "fiche-tres-photographiee": by_imgs[-1], "fiche-1-photo": next((l for l in by_imgs if len(l.get("images") or []) <= 1), None), "fiche-desc-longue": max(ls, key=lambda l: len(l.get("description") or "")), "fiche-prix-eleve": max(ls, key=lambda l: l.get("price") or 0), "fiche-sans-superficie": next((l for l in ls if l.get("area_sqft") is None), None), } from urllib.parse import quote for name, l in cases.items(): if l: pages.append((name, f"/propriete/{quote(l['uid'], safe='')}")) except Exception as exc: print(f"⚠ cas limites non échantillonnés ({exc}) — routes fixes seulement") return pages def main() -> int: OUT.mkdir(parents=True, exist_ok=True) pages = pick_cases() rows = [] failures = 0 with sync_playwright() as p: # --use-gl=angle : WebGL2 requis par la carte Mapbox (Ka Maps) en headless browser = p.chromium.launch(args=["--use-gl=angle"]) for vp_name, w, h, mobile in VIEWPORTS: ctx = browser.new_context( viewport={"width": w, "height": h}, is_mobile=mobile, has_touch=mobile, device_scale_factor=2 if mobile else 1, user_agent=("Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) " "AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 " "Mobile/15E148 Safari/604.1") if mobile else None, ) page = ctx.new_page() console_errors: list[str] = [] page.on("console", lambda m: console_errors.append(m.text) if m.type == "error" else None) for page_name, path in pages: console_errors.clear() try: page.goto(BASE + path, wait_until="networkidle", timeout=45000) except Exception: pass # networkidle peut expirer (carte) — on évalue quand même time.sleep(1.2) m = page.evaluate("""() => { const d = document.documentElement; const overflow = Math.max(d.scrollWidth, document.body.scrollWidth) - (window.innerWidth + 1); let broken = 0, loaded = 0; for (const img of document.images) { if (!img.complete) continue; loaded++; if (img.naturalWidth === 0 && img.src.startsWith('http')) broken++; } let smallTargets = 0; for (const el of document.querySelectorAll('a,button')) { const r = el.getBoundingClientRect(); if (r.width > 0 && r.height > 0 && r.height < 40 && r.width < 40) smallTargets++; } return {overflow: Math.max(0, overflow), broken, loaded, smallTargets}; }""") shot = OUT / f"{page_name}--{vp_name}.png" try: page.screenshot(path=str(shot), full_page=(page_name != "recherche-carte")) except Exception: page.screenshot(path=str(shot)) errs = [e for e in console_errors if "favicon" not in e and "maplibre" not in e.lower()] ok = m["overflow"] == 0 and m["broken"] == 0 if not ok: failures += 1 rows.append({ "page": page_name, "viewport": vp_name, "ok": ok, "debordement_px": m["overflow"], "img_cassees": m["broken"], "img_chargees": m["loaded"], "cibles_petites": m["smallTargets"], "erreurs_js": len(errs), }) flag = "OK " if ok else "ÉCHEC" print(f"[{flag}] {page_name:28s} {vp_name:12s} " f"overflow={m['overflow']}px imgs cassées={m['broken']} " f"petites cibles={m['smallTargets']} jsErr={len(errs)}") ctx.close() browser.close() # rapport markdown md = ["# Rapport de validation du rendu — Immo-Ka", f"\nDate : {date.today().isoformat()} · Base : {BASE}", f"\nCombinaisons testées : {len(rows)} — échecs : {failures}\n", "| Page | Largeur | Statut | Débordement | Img cassées | Petites cibles | Erreurs JS |", "|---|---|---|---|---|---|---|"] for r in rows: md.append(f"| {r['page']} | {r['viewport']} | " f"{'✅ OK' if r['ok'] else '❌ ÉCHEC'} | {r['debordement_px']} px | " f"{r['img_cassees']}/{r['img_chargees']} | {r['cibles_petites']} | " f"{r['erreurs_js']} |") (OUT / "rapport-validation.md").write_text("\n".join(md), encoding="utf-8") (OUT / "rapport-validation.json").write_text(json.dumps(rows, indent=1), encoding="utf-8") print(f"\nRapport : {OUT}/rapport-validation.md — {failures} échec(s)") return 1 if failures else 0 if __name__ == "__main__": sys.exit(main())