Python 67%
TypeScript 18.2%
CSS 14.4%
1# -----------------------------------------------------------------------------2# Immo-Ka — Agrégateur de maisons à vendre (province de Québec)3# Auteur : Simon-Pierre Boucher — contact@spboucher.ai4# scripts/valider_rendu.py : validation automatisée du rendu d'affichage.5#6# Matrice largeurs × pages (Playwright/Chromium) : pour chaque combinaison —7# · AUCUN débordement horizontal (scrollWidth <= largeur de la fenêtre)8# · aucune image cassée parmi les images chargées (naturalWidth > 0)9# · cibles tactiles : liens/boutons visibles < 40 px de haut signalés10# · erreurs console JavaScript11# · capture d'écran pleine page (régression visuelle : comparer les runs)12#13# Usage : python3 scripts/valider_rendu.py [BASE_URL] [DOSSIER_SORTIE]14# Par défaut : http://localhost:18096 (tunnel SSH vers M4M64a:8096) et15# docs/screenshots/validation-<date>/ . Code retour 1 si un échec bloquant.16# -----------------------------------------------------------------------------17from __future__ import annotations1819import json20import sys21import time22import urllib.request23from datetime import date24from pathlib import Path2526from playwright.sync_api import sync_playwright2728BASE = sys.argv[1] if len(sys.argv) > 1 else "http://localhost:18096"29OUT = Path(sys.argv[2] if len(sys.argv) > 2 else30 f"docs/screenshots/validation-{date.today().isoformat()}")3132# matrice de largeurs : smartphones (portrait), paysage mobile, tablette, desktop33VIEWPORTS = [34 ("mobile-360", 360, 780, True),35 ("mobile-390", 390, 844, True),36 ("mobile-414", 414, 896, True),37 ("paysage-844", 844, 390, True),38 ("tablette-768", 768, 1024, True),39 ("desktop-1024", 1024, 768, False),40 ("desktop-1440", 1440, 900, False),41]424344def _api(path: str):45 with urllib.request.urlopen(BASE + path, timeout=30) as r:46 return json.loads(r.read().decode())474849def pick_cases() -> list[tuple[str, str]]:50 """Pages à tester — routes fixes + cas limites réels choisis dans l'API."""51 pages: list[tuple[str, str]] = [52 ("accueil", "/"),53 ("recherche-carte", "/?view=carte"),54 ("recherche-sans-resultat", "/?q=zzzz-introuvable-zzz"),55 ("agences", "/agences"),56 ("stats", "/stats"),57 ("contact", "/contact"),58 ]59 try:60 ls = _api("/api/listings?limit=500")["listings"]61 by_imgs = sorted(ls, key=lambda l: len(l.get("images") or []))62 cases = {63 "fiche-tres-photographiee": by_imgs[-1],64 "fiche-1-photo": next((l for l in by_imgs if len(l.get("images") or []) <= 1), None),65 "fiche-desc-longue": max(ls, key=lambda l: len(l.get("description") or "")),66 "fiche-prix-eleve": max(ls, key=lambda l: l.get("price") or 0),67 "fiche-sans-superficie": next((l for l in ls if l.get("area_sqft") is None), None),68 }69 from urllib.parse import quote70 for name, l in cases.items():71 if l:72 pages.append((name, f"/propriete/{quote(l['uid'], safe='')}"))73 except Exception as exc:74 print(f"⚠ cas limites non échantillonnés ({exc}) — routes fixes seulement")75 return pages767778def main() -> int:79 OUT.mkdir(parents=True, exist_ok=True)80 pages = pick_cases()81 rows = []82 failures = 083 with sync_playwright() as p:84 # --use-gl=angle : WebGL2 requis par la carte Mapbox (Ka Maps) en headless85 browser = p.chromium.launch(args=["--use-gl=angle"])86 for vp_name, w, h, mobile in VIEWPORTS:87 ctx = browser.new_context(88 viewport={"width": w, "height": h},89 is_mobile=mobile, has_touch=mobile,90 device_scale_factor=2 if mobile else 1,91 user_agent=("Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) "92 "AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 "93 "Mobile/15E148 Safari/604.1") if mobile else None,94 )95 page = ctx.new_page()96 console_errors: list[str] = []97 page.on("console", lambda m: console_errors.append(m.text)98 if m.type == "error" else None)99 for page_name, path in pages:100 console_errors.clear()101 try:102 page.goto(BASE + path, wait_until="networkidle", timeout=45000)103 except Exception:104 pass # networkidle peut expirer (carte) — on évalue quand même105 time.sleep(1.2)106 m = page.evaluate("""() => {107 const d = document.documentElement;108 const overflow = Math.max(d.scrollWidth, document.body.scrollWidth)109 - (window.innerWidth + 1);110 let broken = 0, loaded = 0;111 for (const img of document.images) {112 if (!img.complete) continue;113 loaded++;114 if (img.naturalWidth === 0 && img.src.startsWith('http')) broken++;115 }116 let smallTargets = 0;117 for (const el of document.querySelectorAll('a,button')) {118 const r = el.getBoundingClientRect();119 if (r.width > 0 && r.height > 0 && r.height < 40 && r.width < 40)120 smallTargets++;121 }122 return {overflow: Math.max(0, overflow), broken, loaded, smallTargets};123 }""")124 shot = OUT / f"{page_name}--{vp_name}.png"125 try:126 page.screenshot(path=str(shot), full_page=(page_name != "recherche-carte"))127 except Exception:128 page.screenshot(path=str(shot))129 errs = [e for e in console_errors130 if "favicon" not in e and "maplibre" not in e.lower()]131 ok = m["overflow"] == 0 and m["broken"] == 0132 if not ok:133 failures += 1134 rows.append({135 "page": page_name, "viewport": vp_name, "ok": ok,136 "debordement_px": m["overflow"], "img_cassees": m["broken"],137 "img_chargees": m["loaded"], "cibles_petites": m["smallTargets"],138 "erreurs_js": len(errs),139 })140 flag = "OK " if ok else "ÉCHEC"141 print(f"[{flag}] {page_name:28s} {vp_name:12s} "142 f"overflow={m['overflow']}px imgs cassées={m['broken']} "143 f"petites cibles={m['smallTargets']} jsErr={len(errs)}")144 ctx.close()145 browser.close()146147 # rapport markdown148 md = ["# Rapport de validation du rendu — Immo-Ka",149 f"\nDate : {date.today().isoformat()} · Base : {BASE}",150 f"\nCombinaisons testées : {len(rows)} — échecs : {failures}\n",151 "| Page | Largeur | Statut | Débordement | Img cassées | Petites cibles | Erreurs JS |",152 "|---|---|---|---|---|---|---|"]153 for r in rows:154 md.append(f"| {r['page']} | {r['viewport']} | "155 f"{'✅ OK' if r['ok'] else '❌ ÉCHEC'} | {r['debordement_px']} px | "156 f"{r['img_cassees']}/{r['img_chargees']} | {r['cibles_petites']} | "157 f"{r['erreurs_js']} |")158 (OUT / "rapport-validation.md").write_text("\n".join(md), encoding="utf-8")159 (OUT / "rapport-validation.json").write_text(json.dumps(rows, indent=1),160 encoding="utf-8")161 print(f"\nRapport : {OUT}/rapport-validation.md — {failures} échec(s)")162 return 1 if failures else 0163164165if __name__ == "__main__":166 sys.exit(main())167