Python 33.5%
JavaScript 30.1%
TypeScript 25%
CSS 10%
Shell 1.4%
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 raw = Math.max(document.documentElement.scrollWidth - iw,37 document.body ? document.body.scrollWidth - iw : 0);38 let overflow = 0;39 if (raw > 1) { // scrollWidth ment quand overflow-x:clip — tester le défilement réel40 const x0 = window.scrollX;41 window.scrollBy(raw, 0);42 if (window.scrollX - x0 > 1) overflow = raw;43 window.scrollTo(x0, 0);44 }45 const out = { overflow, smallInputs: 0, viewport: null };46 const mv = document.querySelector('meta[name="viewport"]');47 out.viewport = mv ? mv.getAttribute("content") : null;48 for (const el of document.querySelectorAll("input, select, textarea")) {49 if (el.type === "hidden" || el.type === "checkbox" || el.type === "radio") continue;50 const r = el.getBoundingClientRect();51 if (r.width < 4 || r.height < 4) continue;52 if (parseFloat(getComputedStyle(el).fontSize) < 16) out.smallInputs++;53 }54 return out;55})()`;5657const SCROLLBAR = `(async () => {58 const iw = window.innerWidth, vh = window.innerHeight;59 let bar = null;60 for (const el of document.querySelectorAll("body *")) {61 const cs = getComputedStyle(el);62 if (cs.position !== "fixed" || cs.display === "none") continue;63 const r = el.getBoundingClientRect();64 if (r.height >= 8 && r.bottom > vh - 120 && r.top > vh * 0.4 && r.width >= iw * 0.6) { bar = el; break; }65 }66 if (!bar) return null;67 const pos = [];68 for (let i = 0; i < 3; i++) {69 window.scrollBy(0, 700);70 await new Promise((r) => setTimeout(r, 320));71 const r = bar.getBoundingClientRect();72 pos.push({ off: Math.round(innerHeight - r.bottom), vis: r.height > 0 && getComputedStyle(bar).display !== "none" });73 }74 window.scrollTo(0, 0);75 return pos;76})()`;7778const launcher = engineName === "webkit" ? webkit : chromium;79const browser = await launcher.launch();80let failures = 0;81const rows = [];82for (const site of sites) {83 for (const c of cases) {84 const ctx = await browser.newContext({85 viewport: { width: c.w, height: c.h },86 isMobile: c.w < 800, hasTouch: true,87 userAgent: engineName === "webkit"88 ? undefined89 : "Mozilla/5.0 (Linux; Android 14; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Mobile Safari/537.36",90 });91 const page = await ctx.newPage();92 const label = `${site.id} ${c.w}×${c.h} (${c.tag})`;93 const errs = [];94 try {95 const resp = await page.goto(`https://${site.domain}/`, { waitUntil: "domcontentloaded", timeout: 40000 });96 if (!resp || resp.status() !== 200) errs.push(`HTTP ${resp ? resp.status() : "??"}`);97 await page.waitForLoadState("networkidle", { timeout: 15000 }).catch(() => {});98 await page.waitForTimeout(900);99 const m = await page.evaluate(CHECK);100 if (m.overflow > 1) errs.push(`débordement +${m.overflow}px`);101 if (!m.viewport || !/width=device-width/.test(m.viewport)) errs.push("meta viewport invalide");102 if (m.smallInputs > 0) errs.push(`${m.smallInputs} champ(s) <16px`);103 const track = await page.evaluate(SCROLLBAR).catch(() => null);104 if (track) {105 const offs = track.map((p) => p.off);106 if (Math.max(...offs) - Math.min(...offs) > 2) errs.push(`barre basse instable (${Math.max(...offs) - Math.min(...offs)}px)`);107 if (track.some((p) => !p.vis)) errs.push("barre basse disparaît au scroll");108 }109 await page.screenshot({ path: join(outDir, `${site.id}-${c.w}x${c.h}.png`) });110 } catch (e) {111 errs.push(String(e).split("\n")[0].slice(0, 90));112 }113 if (errs.length) failures++;114 rows.push({ site: site.id, case: `${c.w}x${c.h}`, tag: c.tag, errs });115 console.log(`${errs.length ? "❌" : "✅"} ${label}${errs.length ? " — " + errs.join(" · ") : ""}`);116 await ctx.close();117 }118}119await browser.close();120writeFileSync(join(outDir, "last-run.json"), JSON.stringify({ date: new Date().toISOString(), engine: engineName, rows }, null, 2));121console.log(`\n${rows.length} cas, ${failures} échec(s). Captures + last-run.json : ${outDir}`);122process.exit(failures ? 1 : 0);123