// Auteur : Simon-Pierre Boucher — contact@spboucher.ai // ka-ui/regression-mobile.mjs — RÉGRESSION mobile des 13 sites Groupe KA. // À lancer à CHAQUE mise à jour d'un site (rapide : ~3 min) : // cd ~/Desktop/ka-ui && node regression-mobile.mjs [site…] [--engine=webkit] // Matrice : 360 / 390 / 414 / 768 px, portrait ET paysage. Pour chaque cas : // · capture d'écran (regression/-x.png) // · ÉCHEC si débordement horizontal (> 1 px) // · ÉCHEC si meta viewport absente ou sans width=device-width // · ÉCHEC si champ visible < 16 px (zoom iOS) // · ÉCHEC si une barre basse fixe (pleine largeur) dérive ou disparaît au scroll // Code de sortie ≠ 0 s'il y a au moins un échec → utilisable en CI/cron. import { chromium, webkit } from "playwright"; import { mkdirSync, readFileSync, writeFileSync } from "fs"; import { dirname, join } from "path"; import { fileURLToPath } from "url"; const here = dirname(fileURLToPath(import.meta.url)); const eco = JSON.parse(readFileSync(join(here, "ecosystem.json"), "utf8")); const outDir = join(here, "regression"); mkdirSync(outDir, { recursive: true }); const args = process.argv.slice(2); const engineName = (args.find((a) => a.startsWith("--engine=")) || "--engine=chromium").split("=")[1]; const only = args.filter((a) => !a.startsWith("--")); const sites = eco.sites.filter((s) => !only.length || only.includes(s.id)); const widths = [360, 390, 414, 768]; const cases = []; for (const w of widths) { cases.push({ w, h: Math.round(w * 2.05), tag: "portrait" }); if (w === 390 || w === 768) cases.push({ w: Math.round(w * 2.05), h: w, tag: "paysage" }); } const CHECK = `(() => { const iw = window.innerWidth; const raw = Math.max(document.documentElement.scrollWidth - iw, document.body ? document.body.scrollWidth - iw : 0); let overflow = 0; if (raw > 1) { // scrollWidth ment quand overflow-x:clip — tester le défilement réel const x0 = window.scrollX; window.scrollBy(raw, 0); if (window.scrollX - x0 > 1) overflow = raw; window.scrollTo(x0, 0); } const out = { overflow, smallInputs: 0, viewport: null }; const mv = document.querySelector('meta[name="viewport"]'); out.viewport = mv ? mv.getAttribute("content") : null; for (const el of document.querySelectorAll("input, select, textarea")) { if (el.type === "hidden" || el.type === "checkbox" || el.type === "radio") continue; const r = el.getBoundingClientRect(); if (r.width < 4 || r.height < 4) continue; if (parseFloat(getComputedStyle(el).fontSize) < 16) out.smallInputs++; } return out; })()`; const SCROLLBAR = `(async () => { const iw = window.innerWidth, vh = window.innerHeight; let bar = null; for (const el of document.querySelectorAll("body *")) { const cs = getComputedStyle(el); if (cs.position !== "fixed" || cs.display === "none") continue; const r = el.getBoundingClientRect(); if (r.height >= 8 && r.bottom > vh - 120 && r.top > vh * 0.4 && r.width >= iw * 0.6) { bar = el; break; } } if (!bar) return null; const pos = []; for (let i = 0; i < 3; i++) { window.scrollBy(0, 700); await new Promise((r) => setTimeout(r, 320)); const r = bar.getBoundingClientRect(); pos.push({ off: Math.round(innerHeight - r.bottom), vis: r.height > 0 && getComputedStyle(bar).display !== "none" }); } window.scrollTo(0, 0); return pos; })()`; const launcher = engineName === "webkit" ? webkit : chromium; const browser = await launcher.launch(); let failures = 0; const rows = []; for (const site of sites) { for (const c of cases) { const ctx = await browser.newContext({ viewport: { width: c.w, height: c.h }, isMobile: c.w < 800, hasTouch: true, userAgent: engineName === "webkit" ? undefined : "Mozilla/5.0 (Linux; Android 14; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Mobile Safari/537.36", }); const page = await ctx.newPage(); const label = `${site.id} ${c.w}×${c.h} (${c.tag})`; const errs = []; try { const resp = await page.goto(`https://${site.domain}/`, { waitUntil: "domcontentloaded", timeout: 40000 }); if (!resp || resp.status() !== 200) errs.push(`HTTP ${resp ? resp.status() : "??"}`); await page.waitForLoadState("networkidle", { timeout: 15000 }).catch(() => {}); await page.waitForTimeout(900); const m = await page.evaluate(CHECK); if (m.overflow > 1) errs.push(`débordement +${m.overflow}px`); if (!m.viewport || !/width=device-width/.test(m.viewport)) errs.push("meta viewport invalide"); if (m.smallInputs > 0) errs.push(`${m.smallInputs} champ(s) <16px`); const track = await page.evaluate(SCROLLBAR).catch(() => null); if (track) { const offs = track.map((p) => p.off); if (Math.max(...offs) - Math.min(...offs) > 2) errs.push(`barre basse instable (${Math.max(...offs) - Math.min(...offs)}px)`); if (track.some((p) => !p.vis)) errs.push("barre basse disparaît au scroll"); } await page.screenshot({ path: join(outDir, `${site.id}-${c.w}x${c.h}.png`) }); } catch (e) { errs.push(String(e).split("\n")[0].slice(0, 90)); } if (errs.length) failures++; rows.push({ site: site.id, case: `${c.w}x${c.h}`, tag: c.tag, errs }); console.log(`${errs.length ? "❌" : "✅"} ${label}${errs.length ? " — " + errs.join(" · ") : ""}`); await ctx.close(); } } await browser.close(); writeFileSync(join(outDir, "last-run.json"), JSON.stringify({ date: new Date().toISOString(), engine: engineName, rows }, null, 2)); console.log(`\n${rows.length} cas, ${failures} échec(s). Captures + last-run.json : ${outDir}`); process.exit(failures ? 1 : 0);