1// Auteur : Simon-Pierre Boucher — contact@spboucher.ai2// ka-ui/regression-mobile.mjs — RÉGRESSION mobile des 13 sites Groupe KA.3// À lancer à CHAQUE mise à jour d'un site (rapide : ~3 min) :4// cd ~/Desktop/ka-ui && node regression-mobile.mjs [site…] [--engine=webkit]5// Matrice : 360 / 390 / 414 / 768 px, portrait ET paysage. Pour chaque cas :6// · capture d'écran (regression/<site>-<l>x<h>.png)7// · ÉCHEC si débordement horizontal (> 1 px)8// · ÉCHEC si meta viewport absente ou sans width=device-width9// · ÉCHEC si champ visible < 16 px (zoom iOS)10// · ÉCHEC si une barre basse fixe (pleine largeur) dérive ou disparaît au scroll11// Code de sortie ≠ 0 s'il y a au moins un échec → utilisable en CI/cron.12import { chromium, webkit } from "playwright";13import { mkdirSync, readFileSync, writeFileSync } from "fs";14import { dirname, join } from "path";15import { fileURLToPath } from "url";1617const here = dirname(fileURLToPath(import.meta.url));18const eco = JSON.parse(readFileSync(join(here, "ecosystem.json"), "utf8"));19const outDir = join(here, "regression");20mkdirSync(outDir, { recursive: true });2122const args = process.argv.slice(2);23const engineName = (args.find((a) => a.startsWith("--engine=")) || "--engine=chromium").split("=")[1];24const only = args.filter((a) => !a.startsWith("--"));25const sites = eco.sites.filter((s) => !only.length || only.includes(s.id));2627const widths = [360, 390, 414, 768];28const cases = [];29for (const w of widths) {30 cases.push({ w, h: Math.round(w * 2.05), tag: "portrait" });31 if (w === 390 || w === 768) cases.push({ w: Math.round(w * 2.05), h: w, tag: "paysage" });32}3334const CHECK = `(() => {35 const iw = window.innerWidth;36 const out = { overflow: Math.max(document.documentElement.scrollWidth - iw,37 document.body ? document.body.scrollWidth - iw : 0), smallInputs: 0, viewport: null };38 const mv = document.querySelector('meta[name="viewport"]');39 out.viewport = mv ? mv.getAttribute("content") : null;40 for (const el of document.querySelectorAll("input, select, textarea")) {41 if (el.type === "hidden" || el.type === "checkbox" || el.type === "radio") continue;42 const r = el.getBoundingClientRect();43 if (r.width < 4 || r.height < 4) continue;44 if (parseFloat(getComputedStyle(el).fontSize) < 16) out.smallInputs++;45 }46 return out;47})()`;4849const SCROLLBAR = `(async () => {50 const iw = window.innerWidth, vh = window.innerHeight;51 let bar = null;52 for (const el of document.querySelectorAll("body *")) {53 const cs = getComputedStyle(el);54 if (cs.position !== "fixed" || cs.display === "none") continue;55 const r = el.getBoundingClientRect();56 if (r.height >= 8 && r.bottom > vh - 120 && r.top > vh * 0.4 && r.width >= iw * 0.6) { bar = el; break; }57 }58 if (!bar) return null;59 const pos = [];60 for (let i = 0; i < 3; i++) {61 window.scrollBy(0, 700);62 await new Promise((r) => setTimeout(r, 320));63 const r = bar.getBoundingClientRect();64 pos.push({ off: Math.round(innerHeight - r.bottom), vis: r.height > 0 && getComputedStyle(bar).display !== "none" });65 }66 window.scrollTo(0, 0);67 return pos;68})()`;6970const launcher = engineName === "webkit" ? webkit : chromium;71const browser = await launcher.launch();72let failures = 0;73const rows = [];74for (const site of sites) {75 for (const c of cases) {76 const ctx = await browser.newContext({77 viewport: { width: c.w, height: c.h },78 isMobile: c.w < 800, hasTouch: true,79 userAgent: engineName === "webkit"80 ? undefined81 : "Mozilla/5.0 (Linux; Android 14; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Mobile Safari/537.36",82 });83 const page = await ctx.newPage();84 const label = `${site.id} ${c.w}×${c.h} (${c.tag})`;85 const errs = [];86 try {87 const resp = await page.goto(`https://${site.domain}/`, { waitUntil: "domcontentloaded", timeout: 40000 });88 if (!resp || resp.status() !== 200) errs.push(`HTTP ${resp ? resp.status() : "??"}`);89 await page.waitForLoadState("networkidle", { timeout: 15000 }).catch(() => {});90 await page.waitForTimeout(900);91 const m = await page.evaluate(CHECK);92 if (m.overflow > 1) errs.push(`débordement +${m.overflow}px`);93 if (!m.viewport || !/width=device-width/.test(m.viewport)) errs.push("meta viewport invalide");94 if (m.smallInputs > 0) errs.push(`${m.smallInputs} champ(s) <16px`);95 const track = await page.evaluate(SCROLLBAR).catch(() => null);96 if (track) {97 const offs = track.map((p) => p.off);98 if (Math.max(...offs) - Math.min(...offs) > 2) errs.push(`barre basse instable (${Math.max(...offs) - Math.min(...offs)}px)`);99 if (track.some((p) => !p.vis)) errs.push("barre basse disparaît au scroll");100 }101 await page.screenshot({ path: join(outDir, `${site.id}-${c.w}x${c.h}.png`) });102 } catch (e) {103 errs.push(String(e).split("\n")[0].slice(0, 90));104 }105 if (errs.length) failures++;106 rows.push({ site: site.id, case: `${c.w}x${c.h}`, tag: c.tag, errs });107 console.log(`${errs.length ? "❌" : "✅"} ${label}${errs.length ? " — " + errs.join(" · ") : ""}`);108 await ctx.close();109 }110}111await browser.close();112writeFileSync(join(outDir, "last-run.json"), JSON.stringify({ date: new Date().toISOString(), engine: engineName, rows }, null, 2));113console.log(`\n${rows.length} cas, ${failures} échec(s). Captures + last-run.json : ${outDir}`);114process.exit(failures ? 1 : 0);115